added formatter

This commit is contained in:
liyunze 2026-08-30 18:50:25 +08:00
parent b7ef043e6c
commit ffa6b84da3
33 changed files with 2398 additions and 1700 deletions

8
.oxfmtrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": [],
"sortTailwindcss": true,
"useTabs": true,
"tabWidth": 4,
"svelte": true
}

View file

@ -1,6 +1,3 @@
{ {
"recommendations": [ "recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
} }

416
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.3.0", "@types/node": "^26.3.0",
"@vitest/browser-playwright": "^4.1.8", "@vitest/browser-playwright": "^4.1.8",
"oxfmt": "^0.65.0",
"playwright": "^1.60.0", "playwright": "^1.60.0",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
@ -111,6 +112,353 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@oxfmt/binding-android-arm-eabi": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.65.0.tgz",
"integrity": "sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-android-arm64": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.65.0.tgz",
"integrity": "sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-darwin-arm64": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.65.0.tgz",
"integrity": "sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-darwin-x64": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.65.0.tgz",
"integrity": "sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-freebsd-x64": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.65.0.tgz",
"integrity": "sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.65.0.tgz",
"integrity": "sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-arm-musleabihf": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.65.0.tgz",
"integrity": "sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-arm64-gnu": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.65.0.tgz",
"integrity": "sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-arm64-musl": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.65.0.tgz",
"integrity": "sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-ppc64-gnu": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.65.0.tgz",
"integrity": "sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-riscv64-gnu": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.65.0.tgz",
"integrity": "sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-riscv64-musl": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.65.0.tgz",
"integrity": "sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-s390x-gnu": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.65.0.tgz",
"integrity": "sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-x64-gnu": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.65.0.tgz",
"integrity": "sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-linux-x64-musl": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.65.0.tgz",
"integrity": "sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-openharmony-arm64": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.65.0.tgz",
"integrity": "sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-win32-arm64-msvc": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.65.0.tgz",
"integrity": "sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-win32-ia32-msvc": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.65.0.tgz",
"integrity": "sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxfmt/binding-win32-x64-msvc": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.65.0.tgz",
"integrity": "sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -929,9 +1277,9 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.70.2", "version": "2.70.3",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz",
"integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==", "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2164,6 +2512,58 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/oxfmt": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.65.0.tgz",
"integrity": "sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinypool": "2.1.0"
},
"bin": {
"oxfmt": "bin/oxfmt"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxfmt/binding-android-arm-eabi": "0.65.0",
"@oxfmt/binding-android-arm64": "0.65.0",
"@oxfmt/binding-darwin-arm64": "0.65.0",
"@oxfmt/binding-darwin-x64": "0.65.0",
"@oxfmt/binding-freebsd-x64": "0.65.0",
"@oxfmt/binding-linux-arm-gnueabihf": "0.65.0",
"@oxfmt/binding-linux-arm-musleabihf": "0.65.0",
"@oxfmt/binding-linux-arm64-gnu": "0.65.0",
"@oxfmt/binding-linux-arm64-musl": "0.65.0",
"@oxfmt/binding-linux-ppc64-gnu": "0.65.0",
"@oxfmt/binding-linux-riscv64-gnu": "0.65.0",
"@oxfmt/binding-linux-riscv64-musl": "0.65.0",
"@oxfmt/binding-linux-s390x-gnu": "0.65.0",
"@oxfmt/binding-linux-x64-gnu": "0.65.0",
"@oxfmt/binding-linux-x64-musl": "0.65.0",
"@oxfmt/binding-openharmony-arm64": "0.65.0",
"@oxfmt/binding-win32-arm64-msvc": "0.65.0",
"@oxfmt/binding-win32-ia32-msvc": "0.65.0",
"@oxfmt/binding-win32-x64-msvc": "0.65.0"
},
"peerDependencies": {
"svelte": "^5.0.0",
"vite-plus": "*"
},
"peerDependenciesMeta": {
"svelte": {
"optional": true
},
"vite-plus": {
"optional": true
}
}
},
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -2571,6 +2971,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinypool": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz",
"integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
},
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",

View file

@ -1,7 +1,7 @@
{ {
"name": "dsec-notebook", "name": "dsec-notebook",
"private": true,
"version": "0.0.1", "version": "0.0.1",
"private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@ -11,7 +11,9 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest", "test:unit": "vitest",
"test": "npm run test:unit -- --run" "test": "npm run test:unit -- --run",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
@ -20,6 +22,7 @@
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.3.0", "@types/node": "^26.3.0",
"@vitest/browser-playwright": "^4.1.8", "@vitest/browser-playwright": "^4.1.8",
"oxfmt": "^0.65.0",
"playwright": "^1.60.0", "playwright": "^1.60.0",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",

View file

@ -1,13 +1,13 @@
async function call(name: string, args: Record<string, any> = {}): Promise<any> { async function call(name: string, args: Record<string, any> = {}): Promise<any> {
const res = await fetch('/api', { const res = await fetch("/api", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fn: name, args }) body: JSON.stringify({ fn: name, args }),
}); });
const data = await res.json(); const data = await res.json();
if (!data.ok) { if (!data.ok) {
throw new Error(data.error ?? 'Request failed'); throw new Error(data.error ?? "Request failed");
} }
return data.result; return data.result;
} }

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import VoteStack from './VoteStack.svelte'; import VoteStack from "./VoteStack.svelte";
let { let {
href, href,
@ -8,25 +8,27 @@
meta, meta,
voteCount = 0, voteCount = 0,
targetType, targetType,
targetId targetId,
}: { }: {
href: string; href: string;
title: string; title: string;
unitCode?: string; unitCode?: string;
meta: string; meta: string;
voteCount?: number; voteCount?: number;
targetType: 'note' | 'question'; targetType: "note" | "question";
targetId: string; targetId: string;
} = $props(); } = $props();
</script> </script>
<article class="flex gap-4 border-b border-rule py-5"> <article class="border-rule flex gap-4 border-b py-5">
<VoteStack count={voteCount} {targetType} {targetId} /> <VoteStack count={voteCount} {targetType} {targetId} />
<a {href} class="min-w-0 flex-1 group"> <a {href} class="group min-w-0 flex-1">
{#if unitCode} {#if unitCode}
<p class="kicker">{unitCode}</p> <p class="kicker">{unitCode}</p>
{/if} {/if}
<h3 class="mt-1 font-sans text-[15px] font-semibold leading-snug text-ink group-hover:text-primary"> <h3
class="text-ink group-hover:text-primary mt-1 font-sans text-[15px] leading-snug font-semibold"
>
{title} {title}
</h3> </h3>
<p class="kicker mt-1.5">{meta}</p> <p class="kicker mt-1.5">{meta}</p>

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { isAuthenticated, initAuth, logout } from '$lib/stores/auth'; import { isAuthenticated, initAuth, logout } from "$lib/stores/auth";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { page } from '$app/state'; import { page } from "$app/state";
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
let auth = $state(false); let auth = $state(false);
@ -14,24 +14,26 @@
}); });
const path = $derived(page.url.pathname); const path = $derived(page.url.pathname);
const onNotes = $derived(path === '/notes' || path.startsWith('/notes/')); const onNotes = $derived(path === "/notes" || path.startsWith("/notes/"));
const onQuestions = $derived(path === '/questions' || path.startsWith('/questions/')); const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/"));
</script> </script>
<header class="border-b border-rule bg-white"> <header class="border-rule border-b bg-white">
<div class="mx-auto flex h-16 max-w-4xl items-center justify-between px-4 sm:px-6"> <div class="mx-auto flex h-16 max-w-4xl items-center justify-between px-4 sm:px-6">
<a href="/" class="font-serif text-[1.65rem] leading-none text-ink">Notebook</a> <a href="/" class="text-ink font-serif text-[1.65rem] leading-none">Notebook</a>
<nav class="hidden items-center gap-8 md:flex"> <nav class="hidden items-center gap-8 md:flex">
<a href="/notes" class="nav-link {onNotes ? 'nav-link-active' : ''}">Notes</a> <a href="/notes" class="nav-link {onNotes ? 'nav-link-active' : ''}">Notes</a>
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}">Questions</a> <a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}"
>Questions</a
>
{#if auth} {#if auth}
<button <button
type="button" type="button"
class="nav-link" class="nav-link"
onclick={() => { onclick={() => {
logout(); logout();
goto('/'); goto("/");
}} }}
> >
Sign out Sign out
@ -42,34 +44,48 @@
</nav> </nav>
<button <button
class="p-1 text-muted hover:text-ink md:hidden" class="text-muted hover:text-ink p-1 md:hidden"
aria-label="Open menu" aria-label="Open menu"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)} onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
> >
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="square" d="M4 7h16M4 12h16M4 17h16" /> <path stroke-linecap="square" d="M4 7h16M4 12h16M4 17h16" />
</svg> </svg>
</button> </button>
</div> </div>
{#if mobileMenuOpen} {#if mobileMenuOpen}
<nav class="space-y-3 border-t border-rule px-4 py-4 md:hidden"> <nav class="border-rule space-y-3 border-t px-4 py-4 md:hidden">
<a href="/notes" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Notes</a> <a href="/notes" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
<a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Questions</a> >Notes</a
>
<a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
>Questions</a
>
{#if auth} {#if auth}
<button <button
type="button" type="button"
class="nav-link block" class="nav-link block"
onclick={() => { onclick={() => {
logout(); logout();
goto('/'); goto("/");
mobileMenuOpen = false; mobileMenuOpen = false;
}} }}
> >
Sign out Sign out
</button> </button>
{:else} {:else}
<a href="/auth/login" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Sign in</a> <a
href="/auth/login"
class="nav-link block"
onclick={() => (mobileMenuOpen = false)}>Sign in</a
>
{/if} {/if}
</nav> </nav>
{/if} {/if}

View file

@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { mutation } from '$lib/api'; import { mutation } from "$lib/api";
import { getToken } from '$lib/stores/auth'; import { getToken } from "$lib/stores/auth";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
let { let {
count = 0, count = 0,
targetType, targetType,
targetId targetId,
}: { }: {
count?: number; count?: number;
targetType: 'note' | 'question'; targetType: "note" | "question";
targetId: string; targetId: string;
} = $props(); } = $props();
@ -23,13 +23,13 @@
e.stopPropagation(); e.stopPropagation();
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
goto('/auth/login'); goto("/auth/login");
return; return;
} }
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
const result = await mutation('votes:cast', { token, targetType, targetId, value }); const result = await mutation("votes:cast", { token, targetType, targetId, value });
localCount = result.voteCount; localCount = result.voteCount;
userVote = result.userVote; userVote = result.userVote;
} catch { } catch {
@ -43,30 +43,46 @@
<div class="flex w-8 shrink-0 flex-col gap-1"> <div class="flex w-8 shrink-0 flex-col gap-1">
<button <button
type="button" type="button"
class="flex h-8 w-8 items-center justify-center border border-rule text-muted transition-colors hover:border-secondary hover:text-secondary {userVote === 1 class="border-rule text-muted hover:border-secondary hover:text-secondary flex h-8 w-8 items-center justify-center border transition-colors {userVote ===
1
? 'border-primary text-primary' ? 'border-primary text-primary'
: ''}" : ''}"
onclick={(e) => vote(1, e)} onclick={(e) => vote(1, e)}
aria-label="Upvote" aria-label="Upvote"
disabled={busy} disabled={busy}
> >
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
>
<path d="M6 15l6-6 6 6" stroke-linecap="square" /> <path d="M6 15l6-6 6 6" stroke-linecap="square" />
</svg> </svg>
</button> </button>
<div class="flex h-8 w-8 items-center justify-center border border-rule font-sans text-xs text-ink"> <div
class="border-rule text-ink flex h-8 w-8 items-center justify-center border font-sans text-xs"
>
{voteCount} {voteCount}
</div> </div>
<button <button
type="button" type="button"
class="flex h-8 w-8 items-center justify-center border border-rule text-muted transition-colors hover:border-secondary hover:text-secondary {userVote === -1 class="border-rule text-muted hover:border-secondary hover:text-secondary flex h-8 w-8 items-center justify-center border transition-colors {userVote ===
-1
? 'border-primary text-primary' ? 'border-primary text-primary'
: ''}" : ''}"
onclick={(e) => vote(-1, e)} onclick={(e) => vote(-1, e)}
aria-label="Downvote" aria-label="Downvote"
disabled={busy} disabled={busy}
> >
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
>
<path d="M6 9l6 6 6-6" stroke-linecap="square" /> <path d="M6 9l6 6 6-6" stroke-linecap="square" />
</svg> </svg>
</button> </button>

View file

@ -1,7 +1,7 @@
import { getDb } from './db'; import { getDb } from "./db";
import { randomUUID } from 'node:crypto'; import { randomUUID } from "node:crypto";
const DEAKIN_DOMAIN = 'deakin.edu.au'; const DEAKIN_DOMAIN = "deakin.edu.au";
type Db = ReturnType<typeof getDb>; type Db = ReturnType<typeof getDb>;
@ -10,8 +10,8 @@ function newId(): string {
} }
function generateToken(): string { function generateToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = ''; let result = "";
for (let i = 0; i < 64; i++) { for (let i = 0; i < 64; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length)); result += chars.charAt(Math.floor(Math.random() * chars.length));
} }
@ -20,9 +20,11 @@ function generateToken(): string {
function requireAuth(db: Db, token: string): Record<string, any> { function requireAuth(db: Db, token: string): Record<string, any> {
const user = db const user = db
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?') .prepare(
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
)
.get(token) as Record<string, any> | undefined; .get(token) as Record<string, any> | undefined;
if (!user) throw new Error('Not authenticated'); if (!user) throw new Error("Not authenticated");
return user; return user;
} }
@ -34,37 +36,37 @@ function mapQuestion(row: Record<string, any>): Record<string, any> {
function usersRegister(db: Db, args: { email: string; name: string }) { function usersRegister(db: Db, args: { email: string; name: string }) {
const email = args.email.toLowerCase(); const email = args.email.toLowerCase();
const emailDomain = email.split('@')[1]?.toLowerCase(); const emailDomain = email.split("@")[1]?.toLowerCase();
if (emailDomain !== DEAKIN_DOMAIN) { if (emailDomain !== DEAKIN_DOMAIN) {
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`); throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
} }
const existing = db const existing = db
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?') .prepare(
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?",
)
.get(email) as Record<string, any> | undefined; .get(email) as Record<string, any> | undefined;
const token = generateToken(); const token = generateToken();
if (existing) { if (existing) {
db.prepare('UPDATE users SET sessionToken = ? WHERE id = ?').run(token, existing._id); db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, existing._id);
return { userId: existing._id, token, name: existing.name }; return { userId: existing._id, token, name: existing.name };
} }
const id = newId(); const id = newId();
db.prepare('INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)').run( db.prepare(
id, "INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)",
email, ).run(id, email, args.name, token, Date.now());
args.name,
token,
Date.now()
);
return { userId: id, token, name: args.name }; return { userId: id, token, name: args.name };
} }
function usersGetByToken(db: Db, args: { token: string }) { function usersGetByToken(db: Db, args: { token: string }) {
return ( return (
db db
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?') .prepare(
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
)
.get(args.token) ?? null .get(args.token) ?? null
); );
} }
@ -72,54 +74,62 @@ function usersGetByToken(db: Db, args: { token: string }) {
// ---- topics ---- // ---- topics ----
function topicsGetBySlug(db: Db, args: { slug: string }) { function topicsGetBySlug(db: Db, args: { slug: string }) {
return db return (
.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE slug = ?') db
.get(args.slug) ?? null; .prepare("SELECT id AS _id, name, slug, description FROM topics WHERE slug = ?")
.get(args.slug) ?? null
);
} }
function topicsGetAll(db: Db) { function topicsGetAll(db: Db) {
return db.prepare('SELECT id AS _id, name, slug, description FROM topics ORDER BY name ASC').all(); return db
.prepare("SELECT id AS _id, name, slug, description FROM topics ORDER BY name ASC")
.all();
} }
// ---- units ---- // ---- units ----
function unitsGetByCode(db: Db, args: { code: string }) { function unitsGetByCode(db: Db, args: { code: string }) {
return db return (
.prepare('SELECT id AS _id, code, name, description FROM units WHERE code = ?') db
.get(args.code.toUpperCase()) ?? null; .prepare("SELECT id AS _id, code, name, description FROM units WHERE code = ?")
.get(args.code.toUpperCase()) ?? null
);
} }
function unitsGetAll(db: Db) { function unitsGetAll(db: Db) {
return db.prepare('SELECT id AS _id, code, name, description FROM units ORDER BY code ASC').all(); return db
.prepare("SELECT id AS _id, code, name, description FROM units ORDER BY code ASC")
.all();
} }
function unitsCreateCustom(db: Db, args: { code: string; name: string }) { function unitsCreateCustom(db: Db, args: { code: string; name: string }) {
const code = args.code.toUpperCase(); const code = args.code.toUpperCase();
const existing = db.prepare('SELECT id AS _id FROM units WHERE code = ?').get(code) as const existing = db.prepare("SELECT id AS _id FROM units WHERE code = ?").get(code) as
| { _id: string } | { _id: string }
| undefined; | undefined;
if (existing) return existing._id; if (existing) return existing._id;
const id = newId(); const id = newId();
db.prepare('INSERT INTO units (id, code, name) VALUES (?, ?, ?)').run(id, code, args.name); db.prepare("INSERT INTO units (id, code, name) VALUES (?, ?, ?)").run(id, code, args.name);
return id; return id;
} }
// ---- notes ---- // ---- notes ----
const NOTE_COLUMNS = const NOTE_COLUMNS =
'id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount'; "id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount";
function notesCreate( function notesCreate(
db: Db, db: Db,
args: { token: string; title: string; content: string; topicId: string; unitId: string } args: { token: string; title: string; content: string; topicId: string; unitId: string },
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const id = newId(); const id = newId();
const now = Date.now(); const now = Date.now();
db.prepare( db.prepare(
`INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount) `INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)`,
).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now); ).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now);
return id; return id;
} }
@ -128,15 +138,21 @@ function notesList(db: Db, args: { topicId?: string; unitId?: string; limit?: nu
const limit = args.limit ?? 50; const limit = args.limit ?? 50;
if (args.topicId) { if (args.topicId) {
return db return db
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`) .prepare(
`SELECT ${NOTE_COLUMNS} FROM notes WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`,
)
.all(args.topicId, limit); .all(args.topicId, limit);
} }
if (args.unitId) { if (args.unitId) {
return db return db
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`) .prepare(
`SELECT ${NOTE_COLUMNS} FROM notes WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`,
)
.all(args.unitId, limit); .all(args.unitId, limit);
} }
return db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes ORDER BY createdAt DESC LIMIT ?`).all(limit); return db
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes ORDER BY createdAt DESC LIMIT ?`)
.all(limit);
} }
function notesSearch(db: Db, args: { query: string; limit?: number }) { function notesSearch(db: Db, args: { query: string; limit?: number }) {
@ -145,7 +161,9 @@ function notesSearch(db: Db, args: { query: string; limit?: number }) {
.all(args.limit ?? 200) as Record<string, any>[]; .all(args.limit ?? 200) as Record<string, any>[];
const q = args.query.toLowerCase(); const q = args.query.toLowerCase();
return all.filter( return all.filter(
(n) => String(n.title).toLowerCase().includes(q) || String(n.content).toLowerCase().includes(q) (n) =>
String(n.title).toLowerCase().includes(q) ||
String(n.content).toLowerCase().includes(q),
); );
} }
@ -155,28 +173,28 @@ function notesGetById(db: Db, args: { id: string }) {
function notesRemove(db: Db, args: { token: string; id: string }) { function notesRemove(db: Db, args: { token: string; id: string }) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const note = db.prepare('SELECT id, authorId FROM notes WHERE id = ?').get(args.id) as const note = db.prepare("SELECT id, authorId FROM notes WHERE id = ?").get(args.id) as
| { id: string; authorId: string } | { id: string; authorId: string }
| undefined; | undefined;
if (!note || note.authorId !== user._id) throw new Error('Not authorized'); if (!note || note.authorId !== user._id) throw new Error("Not authorized");
db.prepare('DELETE FROM notes WHERE id = ?').run(args.id); db.prepare("DELETE FROM notes WHERE id = ?").run(args.id);
} }
// ---- questions ---- // ---- questions ----
const QUESTION_COLUMNS = const QUESTION_COLUMNS =
'id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved'; "id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved";
function questionsCreate( function questionsCreate(
db: Db, db: Db,
args: { token: string; title: string; content: string; topicId: string; unitId: string } args: { token: string; title: string; content: string; topicId: string; unitId: string },
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const id = newId(); const id = newId();
const now = Date.now(); const now = Date.now();
db.prepare( db.prepare(
`INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved) `INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0)`,
).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now); ).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now);
return id; return id;
} }
@ -185,140 +203,170 @@ function questionsList(db: Db, args: { topicId?: string; unitId?: string; limit?
const limit = args.limit ?? 50; const limit = args.limit ?? 50;
const rows = ( const rows = (
args.topicId args.topicId
? db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`).all(args.topicId, limit) ? db
.prepare(
`SELECT ${QUESTION_COLUMNS} FROM questions WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`,
)
.all(args.topicId, limit)
: args.unitId : args.unitId
? db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`).all(args.unitId, limit) ? db
: db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions ORDER BY createdAt DESC LIMIT ?`).all(limit) .prepare(
`SELECT ${QUESTION_COLUMNS} FROM questions WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`,
)
.all(args.unitId, limit)
: db
.prepare(
`SELECT ${QUESTION_COLUMNS} FROM questions ORDER BY createdAt DESC LIMIT ?`,
)
.all(limit)
) as Record<string, any>[]; ) as Record<string, any>[];
return rows.map(mapQuestion); return rows.map(mapQuestion);
} }
function questionsGetById(db: Db, args: { id: string }) { function questionsGetById(db: Db, args: { id: string }) {
const row = db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`).get(args.id) as const row = db
| Record<string, any> .prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`)
| undefined; .get(args.id) as Record<string, any> | undefined;
return row ? mapQuestion(row) : null; return row ? mapQuestion(row) : null;
} }
function questionsMarkSolved(db: Db, args: { token: string; id: string }) { function questionsMarkSolved(db: Db, args: { token: string; id: string }) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const question = db.prepare('SELECT id, authorId FROM questions WHERE id = ?').get(args.id) as const question = db.prepare("SELECT id, authorId FROM questions WHERE id = ?").get(args.id) as
| { id: string; authorId: string } | { id: string; authorId: string }
| undefined; | undefined;
if (!question || question.authorId !== user._id) throw new Error('Not authorized'); if (!question || question.authorId !== user._id) throw new Error("Not authorized");
db.prepare('UPDATE questions SET solved = 1 WHERE id = ?').run(args.id); db.prepare("UPDATE questions SET solved = 1 WHERE id = ?").run(args.id);
} }
function questionsRemove(db: Db, args: { token: string; id: string }) { function questionsRemove(db: Db, args: { token: string; id: string }) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const question = db.prepare('SELECT id, authorId FROM questions WHERE id = ?').get(args.id) as const question = db.prepare("SELECT id, authorId FROM questions WHERE id = ?").get(args.id) as
| { id: string; authorId: string } | { id: string; authorId: string }
| undefined; | undefined;
if (!question || question.authorId !== user._id) throw new Error('Not authorized'); if (!question || question.authorId !== user._id) throw new Error("Not authorized");
db.prepare('DELETE FROM questions WHERE id = ?').run(args.id); db.prepare("DELETE FROM questions WHERE id = ?").run(args.id);
} }
// ---- comments ---- // ---- comments ----
const COMMENT_COLUMNS = const COMMENT_COLUMNS =
'id AS _id, content, authorId, authorName, parentId, questionId, parentCommentId, createdAt'; "id AS _id, content, authorId, authorName, parentId, questionId, parentCommentId, createdAt";
function commentsCreateOnNote( function commentsCreateOnNote(
db: Db, db: Db,
args: { token: string; content: string; parentId: string; parentCommentId?: string } args: { token: string; content: string; parentId: string; parentCommentId?: string },
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const id = newId(); const id = newId();
db.prepare( db.prepare(
`INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt) `INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(id, args.content, user._id, user.name, args.parentId, args.parentCommentId ?? null, Date.now()); ).run(
db.prepare('UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?').run(args.parentId); id,
args.content,
user._id,
user.name,
args.parentId,
args.parentCommentId ?? null,
Date.now(),
);
db.prepare("UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?").run(args.parentId);
return id; return id;
} }
function commentsCreateOnQuestion( function commentsCreateOnQuestion(
db: Db, db: Db,
args: { token: string; content: string; questionId: string; parentCommentId?: string } args: { token: string; content: string; questionId: string; parentCommentId?: string },
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const id = newId(); const id = newId();
db.prepare( db.prepare(
`INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt) `INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(id, args.content, user._id, user.name, args.questionId, args.parentCommentId ?? null, Date.now()); ).run(
db.prepare('UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?').run(args.questionId); id,
args.content,
user._id,
user.name,
args.questionId,
args.parentCommentId ?? null,
Date.now(),
);
db.prepare("UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?").run(
args.questionId,
);
return id; return id;
} }
function commentsListByNote(db: Db, args: { noteId: string }) { function commentsListByNote(db: Db, args: { noteId: string }) {
return db return db
.prepare(`SELECT ${COMMENT_COLUMNS} FROM comments WHERE parentId = ? ORDER BY createdAt ASC`) .prepare(
`SELECT ${COMMENT_COLUMNS} FROM comments WHERE parentId = ? ORDER BY createdAt ASC`,
)
.all(args.noteId); .all(args.noteId);
} }
function commentsListByQuestion(db: Db, args: { questionId: string }) { function commentsListByQuestion(db: Db, args: { questionId: string }) {
return db return db
.prepare(`SELECT ${COMMENT_COLUMNS} FROM comments WHERE questionId = ? ORDER BY createdAt ASC`) .prepare(
`SELECT ${COMMENT_COLUMNS} FROM comments WHERE questionId = ? ORDER BY createdAt ASC`,
)
.all(args.questionId); .all(args.questionId);
} }
function commentsRemove(db: Db, args: { token: string; id: string }) { function commentsRemove(db: Db, args: { token: string; id: string }) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const comment = db.prepare('SELECT id, authorId FROM comments WHERE id = ?').get(args.id) as const comment = db.prepare("SELECT id, authorId FROM comments WHERE id = ?").get(args.id) as
| { id: string; authorId: string } | { id: string; authorId: string }
| undefined; | undefined;
if (!comment || comment.authorId !== user._id) throw new Error('Not authorized'); if (!comment || comment.authorId !== user._id) throw new Error("Not authorized");
db.prepare('DELETE FROM comments WHERE id = ?').run(args.id); db.prepare("DELETE FROM comments WHERE id = ?").run(args.id);
} }
// ---- votes ---- // ---- votes ----
function votesCast( function votesCast(
db: Db, db: Db,
args: { token: string; targetType: 'note' | 'question'; targetId: string; value: 1 | -1 } args: { token: string; targetType: "note" | "question"; targetId: string; value: 1 | -1 },
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const isNote = args.targetType === 'note'; const isNote = args.targetType === "note";
const table = isNote ? 'notes' : 'questions'; const table = isNote ? "notes" : "questions";
const doc = db.prepare(`SELECT id, voteCount FROM ${table} WHERE id = ?`).get(args.targetId) as const doc = db.prepare(`SELECT id, voteCount FROM ${table} WHERE id = ?`).get(args.targetId) as
| { id: string; voteCount: number } | { id: string; voteCount: number }
| undefined; | undefined;
if (!doc) throw new Error('Not found'); if (!doc) throw new Error("Not found");
const existing = db const existing = db
.prepare('SELECT id, value FROM votes WHERE userId = ? AND targetType = ? AND targetId = ?') .prepare("SELECT id, value FROM votes WHERE userId = ? AND targetType = ? AND targetId = ?")
.get(user._id, args.targetType, args.targetId) as { id: string; value: number } | undefined; .get(user._id, args.targetType, args.targetId) as { id: string; value: number } | undefined;
let voteCount = doc.voteCount; let voteCount = doc.voteCount;
let userVote: number = args.value; let userVote: number = args.value;
db.exec('BEGIN'); db.exec("BEGIN");
try { try {
if (existing) { if (existing) {
if (existing.value === args.value) { if (existing.value === args.value) {
db.prepare('DELETE FROM votes WHERE id = ?').run(existing.id); db.prepare("DELETE FROM votes WHERE id = ?").run(existing.id);
voteCount -= args.value; voteCount -= args.value;
userVote = 0; userVote = 0;
} else { } else {
db.prepare('UPDATE votes SET value = ? WHERE id = ?').run(args.value, existing.id); db.prepare("UPDATE votes SET value = ? WHERE id = ?").run(args.value, existing.id);
voteCount = voteCount - existing.value + args.value; voteCount = voteCount - existing.value + args.value;
} }
} else { } else {
db.prepare('INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, ?, ?, ?)').run( db.prepare(
newId(), "INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, ?, ?, ?)",
user._id, ).run(newId(), user._id, args.targetType, args.targetId, args.value);
args.targetType,
args.targetId,
args.value
);
voteCount += args.value; voteCount += args.value;
} }
db.prepare(`UPDATE ${table} SET voteCount = ? WHERE id = ?`).run(voteCount, args.targetId); db.prepare(`UPDATE ${table} SET voteCount = ? WHERE id = ?`).run(voteCount, args.targetId);
db.exec('COMMIT'); db.exec("COMMIT");
} catch (err) { } catch (err) {
db.exec('ROLLBACK'); db.exec("ROLLBACK");
throw err; throw err;
} }
@ -333,21 +381,29 @@ function getNoteWithDetails(db: Db, args: { id: string }) {
| undefined; | undefined;
if (!note) return null; if (!note) return null;
const topic = db.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE id = ?').get(note.topicId); const topic = db
const unit = db.prepare('SELECT id AS _id, code, name, description FROM units WHERE id = ?').get(note.unitId); .prepare("SELECT id AS _id, name, slug, description FROM topics WHERE id = ?")
.get(note.topicId);
const unit = db
.prepare("SELECT id AS _id, code, name, description FROM units WHERE id = ?")
.get(note.unitId);
const comments = commentsListByNote(db, { noteId: note._id }); const comments = commentsListByNote(db, { noteId: note._id });
return { ...note, topic, unit, comments }; return { ...note, topic, unit, comments };
} }
function getQuestionWithDetails(db: Db, args: { id: string }) { function getQuestionWithDetails(db: Db, args: { id: string }) {
const question = db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`).get(args.id) as const question = db
| Record<string, any> .prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`)
| undefined; .get(args.id) as Record<string, any> | undefined;
if (!question) return null; if (!question) return null;
const topic = db.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE id = ?').get(question.topicId); const topic = db
const unit = db.prepare('SELECT id AS _id, code, name, description FROM units WHERE id = ?').get(question.unitId); .prepare("SELECT id AS _id, name, slug, description FROM topics WHERE id = ?")
.get(question.topicId);
const unit = db
.prepare("SELECT id AS _id, code, name, description FROM units WHERE id = ?")
.get(question.unitId);
const answers = commentsListByQuestion(db, { questionId: question._id }); const answers = commentsListByQuestion(db, { questionId: question._id });
return { ...mapQuestion(question), topic, unit, answers }; return { ...mapQuestion(question), topic, unit, answers };
@ -358,31 +414,31 @@ function getQuestionWithDetails(db: Db, args: { id: string }) {
type Handler = (db: Db, args: any) => any; type Handler = (db: Db, args: any) => any;
const handlers: Record<string, Handler> = { const handlers: Record<string, Handler> = {
'users:register': usersRegister, "users:register": usersRegister,
'users:getByToken': usersGetByToken, "users:getByToken": usersGetByToken,
'topics:getBySlug': topicsGetBySlug, "topics:getBySlug": topicsGetBySlug,
'topics:getAll': topicsGetAll, "topics:getAll": topicsGetAll,
'units:getByCode': unitsGetByCode, "units:getByCode": unitsGetByCode,
'units:getAll': unitsGetAll, "units:getAll": unitsGetAll,
'units:createCustom': unitsCreateCustom, "units:createCustom": unitsCreateCustom,
'notes:create': notesCreate, "notes:create": notesCreate,
'notes:list': notesList, "notes:list": notesList,
'notes:search': notesSearch, "notes:search": notesSearch,
'notes:getById': notesGetById, "notes:getById": notesGetById,
'notes:remove': notesRemove, "notes:remove": notesRemove,
'questions:create': questionsCreate, "questions:create": questionsCreate,
'questions:list': questionsList, "questions:list": questionsList,
'questions:getById': questionsGetById, "questions:getById": questionsGetById,
'questions:markSolved': questionsMarkSolved, "questions:markSolved": questionsMarkSolved,
'questions:remove': questionsRemove, "questions:remove": questionsRemove,
'comments:createOnNote': commentsCreateOnNote, "comments:createOnNote": commentsCreateOnNote,
'comments:createOnQuestion': commentsCreateOnQuestion, "comments:createOnQuestion": commentsCreateOnQuestion,
'comments:listByNote': commentsListByNote, "comments:listByNote": commentsListByNote,
'comments:listByQuestion': commentsListByQuestion, "comments:listByQuestion": commentsListByQuestion,
'comments:remove': commentsRemove, "comments:remove": commentsRemove,
'votes:cast': votesCast, "votes:cast": votesCast,
'details:getNoteWithDetails': getNoteWithDetails, "details:getNoteWithDetails": getNoteWithDetails,
'details:getQuestionWithDetails': getQuestionWithDetails, "details:getQuestionWithDetails": getQuestionWithDetails,
}; };
export function call(fn: string, args: Record<string, any> = {}): any { export function call(fn: string, args: Record<string, any> = {}): any {

View file

@ -1,9 +1,9 @@
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from 'node:fs'; import { mkdirSync } from "node:fs";
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from "node:path";
import { randomUUID } from 'node:crypto'; import { randomUUID } from "node:crypto";
const DB_PATH = resolve(process.env.DATABASE_PATH ?? 'data/dsec.db'); const DB_PATH = resolve(process.env.DATABASE_PATH ?? "data/dsec.db");
let db: DatabaseSync | null = null; let db: DatabaseSync | null = null;
@ -92,62 +92,118 @@ function createSchema(database: DatabaseSync) {
} }
const SEED_TOPICS = [ const SEED_TOPICS = [
{ name: 'Algorithms', slug: 'algorithms', description: 'Algorithm design, analysis, and common patterns' }, {
{ name: 'Data Structures', slug: 'data-structures', description: 'Arrays, linked lists, trees, graphs, and more' }, name: "Algorithms",
{ name: 'Networking', slug: 'networking', description: 'Computer networks, protocols, and architectures' }, slug: "algorithms",
{ name: 'Cybersecurity', slug: 'cybersecurity', description: 'Security principles, threats, and defenses' }, description: "Algorithm design, analysis, and common patterns",
{ name: 'Databases', slug: 'databases', description: 'Relational and NoSQL databases, SQL, and design' }, },
{ name: 'Web Development', slug: 'web-development', description: 'HTML, CSS, JavaScript, and frameworks' }, {
{ name: 'Operating Systems', slug: 'operating-systems', description: 'OS concepts, processes, memory, and file systems' }, name: "Data Structures",
{ name: 'Software Engineering', slug: 'software-engineering', description: 'Design patterns, testing, and methodologies' }, slug: "data-structures",
{ name: 'Programming Languages', slug: 'programming-languages', description: 'Language concepts, paradigms, and syntax' }, description: "Arrays, linked lists, trees, graphs, and more",
{ name: 'Mathematics', slug: 'mathematics', description: 'Discrete math, linear algebra, statistics for CS' }, },
{ name: 'Machine Learning', slug: 'machine-learning', description: 'ML concepts, models, and techniques' }, {
{ name: 'Cloud Computing', slug: 'cloud-computing', description: 'Cloud platforms, services, and architecture' }, name: "Networking",
{ name: 'Mobile Development', slug: 'mobile-development', description: 'iOS, Android, and cross-platform development' }, slug: "networking",
{ name: 'DevOps', slug: 'devops', description: 'CI/CD, containers, and infrastructure' }, description: "Computer networks, protocols, and architectures",
{ name: 'Computer Architecture', slug: 'computer-architecture', description: 'CPU, memory, and hardware design' }, },
{
name: "Cybersecurity",
slug: "cybersecurity",
description: "Security principles, threats, and defenses",
},
{
name: "Databases",
slug: "databases",
description: "Relational and NoSQL databases, SQL, and design",
},
{
name: "Web Development",
slug: "web-development",
description: "HTML, CSS, JavaScript, and frameworks",
},
{
name: "Operating Systems",
slug: "operating-systems",
description: "OS concepts, processes, memory, and file systems",
},
{
name: "Software Engineering",
slug: "software-engineering",
description: "Design patterns, testing, and methodologies",
},
{
name: "Programming Languages",
slug: "programming-languages",
description: "Language concepts, paradigms, and syntax",
},
{
name: "Mathematics",
slug: "mathematics",
description: "Discrete math, linear algebra, statistics for CS",
},
{
name: "Machine Learning",
slug: "machine-learning",
description: "ML concepts, models, and techniques",
},
{
name: "Cloud Computing",
slug: "cloud-computing",
description: "Cloud platforms, services, and architecture",
},
{
name: "Mobile Development",
slug: "mobile-development",
description: "iOS, Android, and cross-platform development",
},
{ name: "DevOps", slug: "devops", description: "CI/CD, containers, and infrastructure" },
{
name: "Computer Architecture",
slug: "computer-architecture",
description: "CPU, memory, and hardware design",
},
]; ];
const SEED_UNITS = [ const SEED_UNITS = [
{ code: 'SIT102', name: 'Introduction to Programming' }, { code: "SIT102", name: "Introduction to Programming" },
{ code: 'SIT111', name: 'Computer Systems' }, { code: "SIT111", name: "Computer Systems" },
{ code: 'SIT192', name: 'Discrete Mathematics' }, { code: "SIT192", name: "Discrete Mathematics" },
{ code: 'SIT202', name: 'Computer Networks and Communication' }, { code: "SIT202", name: "Computer Networks and Communication" },
{ code: 'SIT210', name: 'Embedded Systems Development' }, { code: "SIT210", name: "Embedded Systems Development" },
{ code: 'SIT221', name: 'Data Structures and Algorithms' }, { code: "SIT221", name: "Data Structures and Algorithms" },
{ code: 'SIT232', name: 'Object-Oriented Development' }, { code: "SIT232", name: "Object-Oriented Development" },
{ code: 'SIT281', name: 'Cryptography' }, { code: "SIT281", name: "Cryptography" },
{ code: 'SIT282', name: 'Computer Forensics' }, { code: "SIT282", name: "Computer Forensics" },
{ code: 'SIT283', name: 'Ethical Hacking' }, { code: "SIT283", name: "Ethical Hacking" },
{ code: 'SIT284', name: 'Cyber Security Management' }, { code: "SIT284", name: "Cyber Security Management" },
{ code: 'SIT313', name: 'Full Stack Web Development' }, { code: "SIT313", name: "Full Stack Web Development" },
{ code: 'SIT315', name: 'Programming Paradigms' }, { code: "SIT315", name: "Programming Paradigms" },
{ code: 'SIT323', name: 'Cloud Native Application Development' }, { code: "SIT323", name: "Cloud Native Application Development" },
{ code: 'SIT331', name: 'IT Security' }, { code: "SIT331", name: "IT Security" },
{ code: 'SIT374', name: 'Team Project (A) - Project Management' }, { code: "SIT374", name: "Team Project (A) - Project Management" },
{ code: 'SIT378', name: 'Team Project (B) - Execution' }, { code: "SIT378", name: "Team Project (B) - Execution" },
{ code: 'SIT379', name: 'Ethical Hacking' }, { code: "SIT379", name: "Ethical Hacking" },
{ code: 'SIT384', name: 'Cyber Security Analytics' }, { code: "SIT384", name: "Cyber Security Analytics" },
{ code: 'SIT393', name: 'Computing Internship' }, { code: "SIT393", name: "Computing Internship" },
{ code: 'MIS771', name: 'Business Intelligence and Data Warehousing' }, { code: "MIS771", name: "Business Intelligence and Data Warehousing" },
{ code: 'MIS772', name: 'Predictive Analytics' }, { code: "MIS772", name: "Predictive Analytics" },
{ code: 'MIS782', name: 'Information Security Governance' }, { code: "MIS782", name: "Information Security Governance" },
{ code: 'MIS784', name: 'Cyber Security Management and Practices' }, { code: "MIS784", name: "Cyber Security Management and Practices" },
{ code: 'MIS785', name: 'IT Strategy and Governance' }, { code: "MIS785", name: "IT Strategy and Governance" },
{ code: 'MIS798', name: 'Business Process Management' }, { code: "MIS798", name: "Business Process Management" },
]; ];
function seed(database: DatabaseSync) { function seed(database: DatabaseSync) {
const existing = database.prepare('SELECT COUNT(*) AS c FROM topics').get() as { c: number }; const existing = database.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number };
if (existing.c > 0) return; if (existing.c > 0) return;
const insertTopic = database.prepare( const insertTopic = database.prepare(
'INSERT INTO topics (id, name, slug, description) VALUES (?, ?, ?, ?)' "INSERT INTO topics (id, name, slug, description) VALUES (?, ?, ?, ?)",
); );
const insertUnit = database.prepare('INSERT INTO units (id, code, name) VALUES (?, ?, ?)'); const insertUnit = database.prepare("INSERT INTO units (id, code, name) VALUES (?, ?, ?)");
database.exec('BEGIN'); database.exec("BEGIN");
try { try {
for (const topic of SEED_TOPICS) { for (const topic of SEED_TOPICS) {
insertTopic.run(randomUUID(), topic.name, topic.slug, topic.description ?? null); insertTopic.run(randomUUID(), topic.name, topic.slug, topic.description ?? null);
@ -155,9 +211,9 @@ function seed(database: DatabaseSync) {
for (const unit of SEED_UNITS) { for (const unit of SEED_UNITS) {
insertUnit.run(randomUUID(), unit.code, unit.name); insertUnit.run(randomUUID(), unit.code, unit.name);
} }
database.exec('COMMIT'); database.exec("COMMIT");
} catch (err) { } catch (err) {
database.exec('ROLLBACK'); database.exec("ROLLBACK");
throw err; throw err;
} }
} }

View file

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
describe('auth store', () => { describe("auth store", () => {
it('should be importable', () => { it("should be importable", () => {
expect(true).toBe(true); expect(true).toBe(true);
}); });
}); });

View file

@ -1,18 +1,18 @@
import { writable } from 'svelte/store'; import { writable } from "svelte/store";
import { query, mutation } from '$lib/api'; import { query, mutation } from "$lib/api";
import type { UserDoc } from '$lib/types'; import type { UserDoc } from "$lib/types";
export const currentUser = writable<UserDoc | null>(null); export const currentUser = writable<UserDoc | null>(null);
export const isAuthenticated = writable(false); export const isAuthenticated = writable(false);
const STORAGE_KEY = 'dsec_session'; const STORAGE_KEY = "dsec_session";
export async function initAuth() { export async function initAuth() {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored) { if (stored) {
try { try {
const { token } = JSON.parse(stored); const { token } = JSON.parse(stored);
const user = await query('users:getByToken', { token }); const user = await query("users:getByToken", { token });
if (user) { if (user) {
currentUser.set(user as UserDoc); currentUser.set(user as UserDoc);
isAuthenticated.set(true); isAuthenticated.set(true);
@ -26,9 +26,15 @@ export async function initAuth() {
} }
export async function login(email: string, name: string) { export async function login(email: string, name: string) {
const result = await mutation('users:register', { email, name }); const result = await mutation("users:register", { email, name });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token })); localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token }));
currentUser.set({ _id: result.userId, email: email.toLowerCase(), name, sessionToken: result.token, _creationTime: Date.now() } as UserDoc); currentUser.set({
_id: result.userId,
email: email.toLowerCase(),
name,
sessionToken: result.token,
_creationTime: Date.now(),
} as UserDoc);
isAuthenticated.set(true); isAuthenticated.set(true);
return result; return result;
} }

View file

@ -1,7 +1,7 @@
export function timeAgo(ts: number): string { export function timeAgo(ts: number): string {
const diff = Date.now() - ts; const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
if (mins < 1) return 'JUST NOW'; if (mins < 1) return "JUST NOW";
if (mins < 60) return `${mins}M AGO`; if (mins < 60) return `${mins}M AGO`;
const hours = Math.floor(mins / 60); const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}H AGO`; if (hours < 24) return `${hours}H AGO`;

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import Navbar from '$lib/components/Navbar.svelte'; import Navbar from "$lib/components/Navbar.svelte";
import { initAuth } from '$lib/stores/auth'; import { initAuth } from "$lib/stores/auth";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import './layout.css'; import "./layout.css";
let { children } = $props(); let { children } = $props();
@ -22,12 +22,15 @@
<main class="flex-1"> <main class="flex-1">
{@render children()} {@render children()}
</main> </main>
<footer class="mt-16 border-t border-rule"> <footer class="border-rule mt-16 border-t">
<div class="mx-auto flex max-w-4xl flex-col gap-2 px-4 py-8 sm:flex-row sm:items-end sm:justify-between sm:px-6"> <div
class="mx-auto flex max-w-4xl flex-col gap-2 px-4 py-8 sm:flex-row sm:items-end sm:justify-between sm:px-6"
>
<div> <div>
<p class="kicker">Notebook — written by students, for students.</p> <p class="kicker">Notebook — written by students, for students.</p>
<p class="mt-2 text-xs text-faint"> <p class="text-faint mt-2 text-xs">
DSEC Notebook is a community resource for Deakin University students. Not affiliated with Deakin University. DSEC Notebook is a community resource for Deakin University students. Not
affiliated with Deakin University.
</p> </p>
</div> </div>
<a href="/search" class="kicker hover:text-primary">Search</a> <a href="/search" class="kicker hover:text-primary">Search</a>

View file

@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { query } from '$lib/api'; import { query } from "$lib/api";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import type { UnitDoc } from '$lib/types'; import type { UnitDoc } from "$lib/types";
let units: UnitDoc[] = $state([]); let units: UnitDoc[] = $state([]);
let loading = $state(true); let loading = $state(true);
onMount(async () => { onMount(async () => {
units = (await query('units:getAll')) as UnitDoc[]; units = (await query("units:getAll")) as UnitDoc[];
loading = false; loading = false;
}); });
</script> </script>
@ -17,13 +17,16 @@
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
<section class="pb-16 pt-6"> <section class="pt-6 pb-16">
<p class="kicker">Written by students</p> <p class="kicker">Written by students</p>
<h1 class="mt-4 max-w-xl font-serif text-4xl font-medium leading-[1.15] text-ink sm:text-5xl"> <h1
class="text-ink mt-4 max-w-xl font-serif text-4xl leading-[1.15] font-medium sm:text-5xl"
>
One notebook for every unit, every topic. One notebook for every unit, every topic.
</h1> </h1>
<p class="mt-5 max-w-lg text-[15px] leading-relaxed text-muted"> <p class="text-muted mt-5 max-w-lg text-[15px] leading-relaxed">
Post notes in markdown, ask questions, and browse by unit. A shared notebook for Deakin students studying IT, computer science, and cybersecurity. Post notes in markdown, ask questions, and browse by unit. A shared notebook for Deakin
students studying IT, computer science, and cybersecurity.
</p> </p>
<div class="mt-8 flex flex-wrap gap-3"> <div class="mt-8 flex flex-wrap gap-3">
<a href="/notes" class="btn-primary">Browse notes</a> <a href="/notes" class="btn-primary">Browse notes</a>
@ -31,7 +34,7 @@
</div> </div>
</section> </section>
<section class="border-t border-rule pt-10"> <section class="border-rule border-t pt-10">
<p class="kicker mb-6">Units</p> <p class="kicker mb-6">Units</p>
{#if loading} {#if loading}
<p class="kicker">Loading</p> <p class="kicker">Loading</p>
@ -40,10 +43,12 @@
{#each units as unit} {#each units as unit}
<a <a
href="/units/{unit.code}" href="/units/{unit.code}"
class="group flex items-baseline justify-between gap-4 border-b border-rule py-3" class="group border-rule flex items-baseline justify-between gap-4 border-b px-5 py-3"
> >
<span class="font-serif text-lg text-ink group-hover:text-primary">{unit.code}</span> <span class="text-ink group-hover:text-primary font-serif text-lg"
<span class="truncate text-sm text-muted">{unit.name}</span> >{unit.code}</span
>
<span class="text-muted truncate text-sm">{unit.name}</span>
</a> </a>
{/each} {/each}
</div> </div>

View file

@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit'; import { json } from "@sveltejs/kit";
import type { RequestHandler } from './$types'; import type { RequestHandler } from "./$types";
import { call } from '$lib/server/api'; import { call } from "$lib/server/api";
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
let fn: string; let fn: string;
@ -8,18 +8,18 @@ export const POST: RequestHandler = async ({ request }) => {
try { try {
const body = await request.json(); const body = await request.json();
fn = String(body?.fn ?? ''); fn = String(body?.fn ?? "");
args = (body?.args ?? {}) as Record<string, unknown>; args = (body?.args ?? {}) as Record<string, unknown>;
} catch { } catch {
return json({ ok: false, error: 'Invalid request body' }, { status: 400 }); return json({ ok: false, error: "Invalid request body" }, { status: 400 });
} }
try { try {
const result = call(fn, args as Record<string, any>); const result = call(fn, args as Record<string, any>);
return json({ ok: true, result }); return json({ ok: true, result });
} catch (err: any) { } catch (err: any) {
const message = err?.message ?? 'Internal error'; const message = err?.message ?? "Internal error";
const status = message === 'Not authenticated' || message === 'Not authorized' ? 401 : 400; const status = message === "Not authenticated" || message === "Not authorized" ? 401 : 400;
return json({ ok: false, error: message }, { status }); return json({ ok: false, error: message }, { status });
} }
}; };

View file

@ -1,32 +1,32 @@
<script lang="ts"> <script lang="ts">
import { login } from '$lib/stores/auth'; import { login } from "$lib/stores/auth";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
let email = $state(''); let email = $state("");
let name = $state(''); let name = $state("");
let error = $state(''); let error = $state("");
let loading = $state(false); let loading = $state(false);
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
error = ''; error = "";
if (!email.trim() || !name.trim()) { if (!email.trim() || !name.trim()) {
error = 'Please fill in all fields'; error = "Please fill in all fields";
return; return;
} }
if (!email.toLowerCase().endsWith('@deakin.edu.au')) { if (!email.toLowerCase().endsWith("@deakin.edu.au")) {
error = 'Only @deakin.edu.au email addresses are allowed'; error = "Only @deakin.edu.au email addresses are allowed";
return; return;
} }
loading = true; loading = true;
try { try {
await login(email.trim(), name.trim()); await login(email.trim(), name.trim());
goto('/'); goto("/");
} catch (err: any) { } catch (err: any) {
error = err.message ?? 'Login failed. Please try again.'; error = err.message ?? "Login failed. Please try again.";
} finally { } finally {
loading = false; loading = false;
} }
@ -40,13 +40,20 @@
<div class="page flex min-h-[calc(100vh-220px)] items-center"> <div class="page flex min-h-[calc(100vh-220px)] items-center">
<div class="mx-auto w-full max-w-md"> <div class="mx-auto w-full max-w-md">
<p class="kicker">Account</p> <p class="kicker">Account</p>
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">Sign in</h1> <h1 class="text-ink mt-2 font-serif text-4xl font-medium">Sign in</h1>
<p class="mt-2 mb-8 text-[15px] text-muted">Use your Deakin email to contribute.</p> <p class="text-muted mt-2 mb-8 text-[15px]">Use your Deakin email to contribute.</p>
<form onsubmit={handleSubmit} class="space-y-5 border-t border-rule pt-8"> <form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
<div> <div>
<label for="name" class="kicker mb-2 block">Full name</label> <label for="name" class="kicker mb-2 block">Full name</label>
<input id="name" type="text" bind:value={name} placeholder="Jane Smith" class="field" required /> <input
id="name"
type="text"
bind:value={name}
placeholder="Enter your name"
class="field"
required
/>
</div> </div>
<div> <div>
@ -55,22 +62,24 @@
id="email" id="email"
type="email" type="email"
bind:value={email} bind:value={email}
placeholder="j.smith@deakin.edu.au" placeholder="@deakin.edu.au"
class="field" class="field"
required required
/> />
<p class="mt-2 text-xs text-faint">Must be an @deakin.edu.au address</p> <p class="text-faint mt-2 text-xs">Must be an @deakin.edu.au address</p>
</div> </div>
{#if error} {#if error}
<p class="text-sm text-primary">{error}</p> <p class="text-primary text-sm">{error}</p>
{/if} {/if}
<button type="submit" disabled={loading} class="btn-primary w-full"> <button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? 'Signing in...' : 'Sign in / Register'} {loading ? "Signing in..." : "Sign in / Register"}
</button> </button>
<p class="text-xs text-faint">By signing in, you agree that your contributions are public.</p> <p class="text-faint text-xs">
By signing in, you agree that your contributions are public.
</p>
</form> </form>
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
@import 'tailwindcss'; @import "tailwindcss";
@theme { @theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: 'EB Garamond', 'Iowan Old Style', Palatino, Georgia, serif; --font-serif: "EB Garamond", "Iowan Old Style", Palatino, Georgia, serif;
--color-primary: #ea4c65; --color-primary: #ea4c65;
--color-primary-dark: #d43d56; --color-primary-dark: #d43d56;
--color-secondary: #00bcd4; --color-secondary: #00bcd4;
@ -25,35 +25,35 @@ html {
} }
.kicker { .kicker {
@apply font-sans text-[11px] font-medium uppercase tracking-[0.18em] text-faint; @apply text-faint font-sans text-[11px] font-medium tracking-[0.18em] uppercase;
} }
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center rounded-none border border-primary bg-primary px-5 py-2.5 text-[11px] font-medium uppercase tracking-[0.14em] text-white transition-colors hover:bg-primary-dark hover:border-primary-dark disabled:cursor-not-allowed disabled:opacity-50; @apply border-primary bg-primary hover:bg-primary-dark hover:border-primary-dark inline-flex items-center justify-center rounded-none border px-5 py-2.5 text-[11px] font-medium tracking-[0.14em] text-white uppercase transition-colors disabled:cursor-not-allowed disabled:opacity-50;
} }
.btn-secondary { .btn-secondary {
@apply inline-flex items-center justify-center rounded-none border border-secondary bg-transparent px-5 py-2.5 text-[11px] font-medium uppercase tracking-[0.14em] text-secondary transition-colors hover:bg-secondary hover:text-white disabled:cursor-not-allowed disabled:opacity-50; @apply border-secondary text-secondary hover:bg-secondary inline-flex items-center justify-center rounded-none border bg-transparent px-5 py-2.5 text-[11px] font-medium tracking-[0.14em] uppercase transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-50;
} }
.btn-ghost { .btn-ghost {
@apply inline-flex items-center justify-center rounded-none border border-ink bg-transparent px-5 py-2.5 text-[11px] font-medium uppercase tracking-[0.14em] text-ink transition-colors hover:border-primary hover:text-primary; @apply border-ink text-ink hover:border-primary hover:text-primary inline-flex items-center justify-center rounded-none border bg-transparent px-5 py-2.5 text-[11px] font-medium tracking-[0.14em] uppercase transition-colors;
} }
.field { .field {
@apply block w-full rounded-none border border-rule bg-white px-3 py-2.5 text-sm text-ink outline-none transition-colors placeholder:text-faint focus:border-primary; @apply border-rule text-ink placeholder:text-faint focus:border-primary block w-full rounded-none border bg-white px-3 py-2.5 text-sm transition-colors outline-none;
} }
.chip { .chip {
@apply inline-flex items-center rounded-none border border-rule bg-white px-3 py-1.5 font-sans text-[11px] font-medium uppercase tracking-[0.12em] text-ink transition-colors hover:border-ink; @apply border-rule text-ink hover:border-ink inline-flex items-center rounded-none border bg-white px-3 py-1.5 font-sans text-[11px] font-medium tracking-[0.12em] uppercase transition-colors;
} }
.chip-active { .chip-active {
@apply border-primary bg-primary text-white hover:border-primary-dark hover:bg-primary-dark; @apply border-primary bg-primary hover:border-primary-dark hover:bg-primary-dark text-white;
} }
.nav-link { .nav-link {
@apply text-[11px] font-medium uppercase tracking-[0.2em] text-muted transition-colors hover:text-primary; @apply text-muted hover:text-primary text-[11px] font-medium tracking-[0.2em] uppercase transition-colors;
} }
.nav-link-active { .nav-link-active {

View file

@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import { query } from '$lib/api'; import { query } from "$lib/api";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import FeedRow from '$lib/components/FeedRow.svelte'; import FeedRow from "$lib/components/FeedRow.svelte";
import { timeAgo } from '$lib/time'; import { timeAgo } from "$lib/time";
import type { NoteDoc, UnitDoc } from '$lib/types'; import type { NoteDoc, UnitDoc } from "$lib/types";
let notes: (NoteDoc & { unit?: UnitDoc })[] = $state([]); let notes: (NoteDoc & { unit?: UnitDoc })[] = $state([]);
let units: UnitDoc[] = $state([]); let units: UnitDoc[] = $state([]);
let loading = $state(true); let loading = $state(true);
let selectedUnitId = $state(''); let selectedUnitId = $state("");
let sort = $state<'newest' | 'top'>('newest'); let sort = $state<"newest" | "top">("newest");
onMount(async () => { onMount(async () => {
const [n, u] = await Promise.all([query('notes:list', {}), query('units:getAll')]); const [n, u] = await Promise.all([query("notes:list", {}), query("units:getAll")]);
const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit])); const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit]));
notes = (n as NoteDoc[]).map((note) => ({ ...note, unit: unitMap.get(note.unitId) })); notes = (n as NoteDoc[]).map((note) => ({ ...note, unit: unitMap.get(note.unitId) }));
units = u as UnitDoc[]; units = u as UnitDoc[];
@ -26,7 +26,7 @@
const visible = $derived.by(() => { const visible = $derived.by(() => {
let list = selectedUnitId ? notes.filter((n) => n.unitId === selectedUnitId) : notes; let list = selectedUnitId ? notes.filter((n) => n.unitId === selectedUnitId) : notes;
if (sort === 'top') list = [...list].sort((a, b) => b.voteCount - a.voteCount); if (sort === "top") list = [...list].sort((a, b) => b.voteCount - a.voteCount);
return list; return list;
}); });
</script> </script>
@ -36,17 +36,17 @@
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
<div class="flex items-end justify-between gap-4 border-b border-rule pb-6"> <div class="border-rule flex items-end justify-between gap-4 border-b pb-6">
<h1 class="font-serif text-4xl font-medium text-ink">Notes</h1> <h1 class="text-ink font-serif text-4xl font-medium">Notes</h1>
<a href="/post/note" class="btn-primary">Post a note</a> <a href="/post/note" class="btn-primary">Post a note</a>
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-rule py-4"> <div class="border-rule flex flex-wrap items-center justify-between gap-4 border-b py-4">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
class="chip {selectedUnitId === '' ? 'chip-active' : ''}" class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
onclick={() => (selectedUnitId = '')} onclick={() => (selectedUnitId = "")}
> >
All units All units
</button> </button>
@ -64,14 +64,14 @@
<button <button
type="button" type="button"
class="kicker {sort === 'newest' ? 'text-ink' : ''}" class="kicker {sort === 'newest' ? 'text-ink' : ''}"
onclick={() => (sort = 'newest')} onclick={() => (sort = "newest")}
> >
Newest Newest
</button> </button>
<button <button
type="button" type="button"
class="kicker {sort === 'top' ? 'text-ink' : ''}" class="kicker {sort === 'top' ? 'text-ink' : ''}"
onclick={() => (sort = 'top')} onclick={() => (sort = "top")}
> >
Top Top
</button> </button>
@ -86,15 +86,21 @@
href="/notes/{note._id}" href="/notes/{note._id}"
title={note.title} title={note.title}
unitCode={note.unit?.code} unitCode={note.unit?.code}
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}" meta="{note.authorName} · {timeAgo(
note.createdAt,
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
voteCount={note.voteCount} voteCount={note.voteCount}
targetType="note" targetType="note"
targetId={note._id} targetId={note._id}
/> />
{:else} {:else}
<div class="py-16"> <div class="py-16">
<p class="text-sm text-muted">No notes yet.</p> <p class="text-muted text-sm">No notes yet.</p>
<a href="/post/note" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Post a note</a> <a
href="/post/note"
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
>Post a note</a
>
</div> </div>
{/each} {/each}
{/if} {/if}

View file

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { query, mutation } from '$lib/api'; import { query, mutation } from "$lib/api";
import { isAuthenticated, getToken, currentUser } from '$lib/stores/auth'; import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth";
import { page } from '$app/state'; import { page } from "$app/state";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import VoteStack from '$lib/components/VoteStack.svelte'; import VoteStack from "$lib/components/VoteStack.svelte";
import { timeAgo } from '$lib/time'; import { timeAgo } from "$lib/time";
import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from '$lib/types'; import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
let note: NoteDoc | null = $state(null); let note: NoteDoc | null = $state(null);
let topic: TopicDoc | null = $state(null); let topic: TopicDoc | null = $state(null);
@ -14,15 +14,15 @@
let comments: CommentDoc[] = $state([]); let comments: CommentDoc[] = $state([]);
let loading = $state(true); let loading = $state(true);
let commentText = $state(''); let commentText = $state("");
let commentError = $state(''); let commentError = $state("");
let commentLoading = $state(false); let commentLoading = $state(false);
let deleteLoading = $state(false); let deleteLoading = $state(false);
let isAuthor = $state(false); let isAuthor = $state(false);
onMount(async () => { onMount(async () => {
const id = page.params.id; const id = page.params.id;
const result = await query('details:getNoteWithDetails', { id }); const result = await query("details:getNoteWithDetails", { id });
if (result) { if (result) {
note = result as NoteDoc; note = result as NoteDoc;
topic = (result as any).topic ?? null; topic = (result as any).topic ?? null;
@ -38,23 +38,23 @@
if (!commentText.trim()) return; if (!commentText.trim()) return;
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
commentError = 'Please sign in to comment'; commentError = "Please sign in to comment";
return; return;
} }
commentLoading = true; commentLoading = true;
commentError = ''; commentError = "";
try { try {
await mutation('comments:createOnNote', { await mutation("comments:createOnNote", {
token, token,
content: commentText.trim(), content: commentText.trim(),
parentId: page.params.id parentId: page.params.id,
}); });
commentText = ''; commentText = "";
const updated = await query('details:getNoteWithDetails', { id: page.params.id }); const updated = await query("details:getNoteWithDetails", { id: page.params.id });
if (updated) comments = (updated as any).comments ?? []; if (updated) comments = (updated as any).comments ?? [];
} catch (err: any) { } catch (err: any) {
commentError = err.message ?? 'Failed to post comment'; commentError = err.message ?? "Failed to post comment";
} finally { } finally {
commentLoading = false; commentLoading = false;
} }
@ -63,14 +63,14 @@
async function deleteNote() { async function deleteNote() {
const token = getToken(); const token = getToken();
if (!token || !note) return; if (!token || !note) return;
if (!confirm('Delete this note?')) return; if (!confirm("Delete this note?")) return;
deleteLoading = true; deleteLoading = true;
try { try {
await mutation('notes:remove', { token, id: note._id }); await mutation("notes:remove", { token, id: note._id });
window.location.href = '/notes'; window.location.href = "/notes";
} catch (err: any) { } catch (err: any) {
alert(err.message ?? 'Failed to delete'); alert(err.message ?? "Failed to delete");
} finally { } finally {
deleteLoading = false; deleteLoading = false;
} }
@ -78,7 +78,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{note?.title ?? 'Loading...'} — Notebook</title> <title>{note?.title ?? "Loading..."} — Notebook</title>
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
@ -89,9 +89,13 @@
<VoteStack count={note.voteCount} targetType="note" targetId={note._id} /> <VoteStack count={note.voteCount} targetType="note" targetId={note._id} />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="kicker"> <p class="kicker">
{#if unit}{unit.code}{/if}{#if unit && topic} · {/if}{#if topic}{topic.name}{/if} {#if unit}{unit.code}{/if}{#if unit && topic}
·
{/if}{#if topic}{topic.name}{/if}
</p> </p>
<h1 class="mt-2 font-serif text-3xl font-medium leading-tight text-ink sm:text-4xl">{note.title}</h1> <h1 class="text-ink mt-2 font-serif text-3xl leading-tight font-medium sm:text-4xl">
{note.title}
</h1>
<p class="kicker mt-3"> <p class="kicker mt-3">
{note.authorName} · {timeAgo(note.createdAt)} {note.authorName} · {timeAgo(note.createdAt)}
{#if isAuthor} {#if isAuthor}
@ -102,68 +106,86 @@
disabled={deleteLoading} disabled={deleteLoading}
class="text-primary hover:text-primary-dark" class="text-primary hover:text-primary-dark"
> >
{deleteLoading ? 'Deleting...' : 'Delete'} {deleteLoading ? "Deleting..." : "Delete"}
</button> </button>
{/if} {/if}
</p> </p>
</div> </div>
</div> </div>
<div class="mt-8 whitespace-pre-wrap border-t border-rule pt-8 text-[15px] leading-relaxed text-ink"> <div
class="border-rule text-ink mt-8 border-t pt-8 text-[15px] leading-relaxed whitespace-pre-wrap"
>
{note.content} {note.content}
</div> </div>
<section class="mt-12 border-t border-rule pt-8"> <section class="border-rule mt-12 border-t pt-8">
<p class="kicker mb-6">Comments ({comments.length})</p> <p class="kicker mb-6">Comments ({comments.length})</p>
{#if get(isAuthenticated)} {#if get(isAuthenticated)}
<div class="mb-8"> <div class="mb-8">
<textarea bind:value={commentText} rows={3} placeholder="Add a comment..." class="field resize-y"></textarea> <textarea
bind:value={commentText}
rows={3}
placeholder="Add a comment..."
class="field resize-y"></textarea>
<div class="mt-3 flex items-center justify-between"> <div class="mt-3 flex items-center justify-between">
<span class="text-xs text-primary">{commentError}</span> <span class="text-primary text-xs">{commentError}</span>
<button <button
onclick={postComment} onclick={postComment}
disabled={commentLoading || !commentText.trim()} disabled={commentLoading || !commentText.trim()}
class="btn-primary" class="btn-primary"
> >
{commentLoading ? 'Posting...' : 'Post comment'} {commentLoading ? "Posting..." : "Post comment"}
</button> </button>
</div> </div>
</div> </div>
{:else} {:else}
<p class="mb-8 text-sm text-muted"> <p class="text-muted mb-8 text-sm">
<a href="/auth/login" class="text-secondary hover:text-secondary-dark">Sign in</a> to leave a comment. <a href="/auth/login" class="text-secondary hover:text-secondary-dark"
>Sign in</a
> to leave a comment.
</p> </p>
{/if} {/if}
<div> <div>
{#each comments as comment} {#each comments as comment}
<div class="border-t border-rule py-4"> <div class="border-rule border-t py-4">
<p class="kicker">{comment.authorName} · {timeAgo(comment.createdAt)}</p> <p class="kicker">{comment.authorName} · {timeAgo(comment.createdAt)}</p>
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">{comment.content}</p> <p class="text-ink mt-2 text-sm leading-relaxed whitespace-pre-wrap">
{comment.content}
</p>
{#if get(currentUser)?._id === comment.authorId} {#if get(currentUser)?._id === comment.authorId}
<button <button
onclick={async () => { onclick={async () => {
const token = getToken(); const token = getToken();
if (!token) return; if (!token) return;
try { try {
await mutation('comments:remove', { token, id: comment._id }); await mutation("comments:remove", {
const updated = await query('details:getNoteWithDetails', { id: page.params.id }); token,
id: comment._id,
});
const updated = await query("details:getNoteWithDetails", {
id: page.params.id,
});
if (updated) comments = (updated as any).comments ?? []; if (updated) comments = (updated as any).comments ?? [];
} catch (e) {} } catch (e) {}
}} }}
class="mt-2 text-xs text-primary hover:text-primary-dark" class="text-primary hover:text-primary-dark mt-2 text-xs"
>Delete</button> >Delete</button
>
{/if} {/if}
</div> </div>
{:else} {:else}
<p class="text-sm text-muted">No comments yet.</p> <p class="text-muted text-sm">No comments yet.</p>
{/each} {/each}
</div> </div>
</section> </section>
{:else} {:else}
<h1 class="font-serif text-3xl text-ink">Note not found</h1> <h1 class="text-ink font-serif text-3xl">Note not found</h1>
<p class="mt-2 text-sm text-muted">This note may have been deleted or doesn't exist.</p> <p class="text-muted mt-2 text-sm">This note may have been deleted or doesn't exist.</p>
<a href="/notes" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Back to notes</a> <a href="/notes" class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
>Back to notes</a
>
{/if} {/if}
</div> </div>

View file

@ -1,50 +1,50 @@
<script lang="ts"> <script lang="ts">
import { query, mutation } from '$lib/api'; import { query, mutation } from "$lib/api";
import { isAuthenticated, getToken } from '$lib/stores/auth'; import { isAuthenticated, getToken } from "$lib/stores/auth";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import type { TopicDoc, UnitDoc } from '$lib/types'; import type { TopicDoc, UnitDoc } from "$lib/types";
let topics: TopicDoc[] = $state([]); let topics: TopicDoc[] = $state([]);
let units: UnitDoc[] = $state([]); let units: UnitDoc[] = $state([]);
let title = $state(''); let title = $state("");
let content = $state(''); let content = $state("");
let selectedTopicId = $state(''); let selectedTopicId = $state("");
let selectedUnitId = $state(''); let selectedUnitId = $state("");
let customUnit = $state(''); let customUnit = $state("");
let useCustomUnit = $state(false); let useCustomUnit = $state(false);
let error = $state(''); let error = $state("");
let loading = $state(false); let loading = $state(false);
let success = $state(''); let success = $state("");
onMount(async () => { onMount(async () => {
if (!get(isAuthenticated)) { if (!get(isAuthenticated)) {
goto('/auth/login'); goto("/auth/login");
return; return;
} }
const [t, u] = await Promise.all([query('topics:getAll'), query('units:getAll')]); const [t, u] = await Promise.all([query("topics:getAll"), query("units:getAll")]);
topics = t as TopicDoc[]; topics = t as TopicDoc[];
units = u as UnitDoc[]; units = u as UnitDoc[];
}); });
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
error = ''; error = "";
success = ''; success = "";
if (!title.trim() || !content.trim()) { if (!title.trim() || !content.trim()) {
error = 'Title and content are required'; error = "Title and content are required";
return; return;
} }
if (!selectedTopicId) { if (!selectedTopicId) {
error = 'Please select a topic'; error = "Please select a topic";
return; return;
} }
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
error = 'You must be signed in'; error = "You must be signed in";
return; return;
} }
@ -52,38 +52,40 @@
try { try {
let unitId = selectedUnitId; let unitId = selectedUnitId;
if (useCustomUnit && customUnit.trim()) { if (useCustomUnit && customUnit.trim()) {
const existing = units.find((u) => u.code.toLowerCase() === customUnit.trim().toUpperCase()); const existing = units.find(
(u) => u.code.toLowerCase() === customUnit.trim().toUpperCase(),
);
if (existing) { if (existing) {
unitId = existing._id; unitId = existing._id;
} else { } else {
unitId = (await mutation('units:createCustom', { unitId = (await mutation("units:createCustom", {
code: customUnit.trim().toUpperCase(), code: customUnit.trim().toUpperCase(),
name: customUnit.trim().toUpperCase() name: customUnit.trim().toUpperCase(),
})) as string; })) as string;
} }
} }
await mutation('notes:create', { await mutation("notes:create", {
token, token,
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
topicId: selectedTopicId, topicId: selectedTopicId,
unitId unitId,
}); });
success = 'Note published.'; success = "Note published.";
title = ''; title = "";
content = ''; content = "";
selectedTopicId = ''; selectedTopicId = "";
selectedUnitId = ''; selectedUnitId = "";
customUnit = ''; customUnit = "";
useCustomUnit = false; useCustomUnit = false;
setTimeout(() => { setTimeout(() => {
success = ''; success = "";
}, 3000); }, 3000);
} catch (err: any) { } catch (err: any) {
error = err.message ?? 'Failed to publish note'; error = err.message ?? "Failed to publish note";
} finally { } finally {
loading = false; loading = false;
} }
@ -95,16 +97,16 @@
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
<h1 class="font-serif text-4xl font-medium text-ink">Post a note</h1> <h1 class="text-ink font-serif text-4xl font-medium">Post a note</h1>
<p class="mt-2 mb-10 text-[15px] text-muted"> <p class="text-muted mt-2 mb-10 text-[15px]">
Share study notes with the Deakin community. Focus on a specific topic, not an entire unit. Share study notes with the Deakin community. Focus on a specific topic, not an entire unit.
</p> </p>
{#if success} {#if success}
<p class="mb-6 text-sm text-secondary">{success}</p> <p class="text-secondary mb-6 text-sm">{success}</p>
{/if} {/if}
<form onsubmit={handleSubmit} class="space-y-6 border-t border-rule pt-8"> <form onsubmit={handleSubmit} class="border-rule space-y-6 border-t pt-8">
<div> <div>
<label for="title" class="kicker mb-2 block">Title</label> <label for="title" class="kicker mb-2 block">Title</label>
<input <input
@ -125,8 +127,7 @@
rows={10} rows={10}
placeholder="Write your notes here..." placeholder="Write your notes here..."
class="field resize-y" class="field resize-y"
required required></textarea>
></textarea>
</div> </div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
@ -155,25 +156,30 @@
type="button" type="button"
onclick={() => { onclick={() => {
useCustomUnit = !useCustomUnit; useCustomUnit = !useCustomUnit;
customUnit = ''; customUnit = "";
}} }}
class="mt-2 text-xs text-secondary hover:text-secondary-dark" class="text-secondary hover:text-secondary-dark mt-2 text-xs"
> >
{useCustomUnit ? 'Choose from list' : 'Other unit...'} {useCustomUnit ? "Choose from list" : "Other unit..."}
</button> </button>
{#if useCustomUnit} {#if useCustomUnit}
<input type="text" bind:value={customUnit} placeholder="e.g., SIT384" class="field mt-2" /> <input
type="text"
bind:value={customUnit}
placeholder="e.g., SIT384"
class="field mt-2"
/>
{/if} {/if}
</div> </div>
</div> </div>
{#if error} {#if error}
<p class="text-sm text-primary">{error}</p> <p class="text-primary text-sm">{error}</p>
{/if} {/if}
<button type="submit" disabled={loading} class="btn-primary"> <button type="submit" disabled={loading} class="btn-primary">
{loading ? 'Publishing...' : 'Publish note'} {loading ? "Publishing..." : "Publish note"}
</button> </button>
</form> </form>
</div> </div>

View file

@ -1,50 +1,50 @@
<script lang="ts"> <script lang="ts">
import { query, mutation } from '$lib/api'; import { query, mutation } from "$lib/api";
import { isAuthenticated, getToken } from '$lib/stores/auth'; import { isAuthenticated, getToken } from "$lib/stores/auth";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import type { TopicDoc, UnitDoc } from '$lib/types'; import type { TopicDoc, UnitDoc } from "$lib/types";
let topics: TopicDoc[] = $state([]); let topics: TopicDoc[] = $state([]);
let units: UnitDoc[] = $state([]); let units: UnitDoc[] = $state([]);
let title = $state(''); let title = $state("");
let content = $state(''); let content = $state("");
let selectedTopicId = $state(''); let selectedTopicId = $state("");
let selectedUnitId = $state(''); let selectedUnitId = $state("");
let customUnit = $state(''); let customUnit = $state("");
let useCustomUnit = $state(false); let useCustomUnit = $state(false);
let error = $state(''); let error = $state("");
let loading = $state(false); let loading = $state(false);
let success = $state(''); let success = $state("");
onMount(async () => { onMount(async () => {
if (!get(isAuthenticated)) { if (!get(isAuthenticated)) {
goto('/auth/login'); goto("/auth/login");
return; return;
} }
const [t, u] = await Promise.all([query('topics:getAll'), query('units:getAll')]); const [t, u] = await Promise.all([query("topics:getAll"), query("units:getAll")]);
topics = t as TopicDoc[]; topics = t as TopicDoc[];
units = u as UnitDoc[]; units = u as UnitDoc[];
}); });
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
error = ''; error = "";
success = ''; success = "";
if (!title.trim() || !content.trim()) { if (!title.trim() || !content.trim()) {
error = 'Title and content are required'; error = "Title and content are required";
return; return;
} }
if (!selectedTopicId) { if (!selectedTopicId) {
error = 'Please select a topic'; error = "Please select a topic";
return; return;
} }
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
error = 'You must be signed in'; error = "You must be signed in";
return; return;
} }
@ -52,38 +52,40 @@
try { try {
let unitId = selectedUnitId; let unitId = selectedUnitId;
if (useCustomUnit && customUnit.trim()) { if (useCustomUnit && customUnit.trim()) {
const existing = units.find((u) => u.code.toLowerCase() === customUnit.trim().toUpperCase()); const existing = units.find(
(u) => u.code.toLowerCase() === customUnit.trim().toUpperCase(),
);
if (existing) { if (existing) {
unitId = existing._id; unitId = existing._id;
} else { } else {
unitId = (await mutation('units:createCustom', { unitId = (await mutation("units:createCustom", {
code: customUnit.trim().toUpperCase(), code: customUnit.trim().toUpperCase(),
name: customUnit.trim().toUpperCase() name: customUnit.trim().toUpperCase(),
})) as string; })) as string;
} }
} }
await mutation('questions:create', { await mutation("questions:create", {
token, token,
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
topicId: selectedTopicId, topicId: selectedTopicId,
unitId unitId,
}); });
success = 'Question posted.'; success = "Question posted.";
title = ''; title = "";
content = ''; content = "";
selectedTopicId = ''; selectedTopicId = "";
selectedUnitId = ''; selectedUnitId = "";
customUnit = ''; customUnit = "";
useCustomUnit = false; useCustomUnit = false;
setTimeout(() => { setTimeout(() => {
success = ''; success = "";
}, 3000); }, 3000);
} catch (err: any) { } catch (err: any) {
error = err.message ?? 'Failed to post question'; error = err.message ?? "Failed to post question";
} finally { } finally {
loading = false; loading = false;
} }
@ -95,14 +97,16 @@
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
<h1 class="font-serif text-4xl font-medium text-ink">Ask a question</h1> <h1 class="text-ink font-serif text-4xl font-medium">Ask a question</h1>
<p class="mt-2 mb-10 text-[15px] text-muted">Stuck on something? Ask the Deakin community for help.</p> <p class="text-muted mt-2 mb-10 text-[15px]">
Stuck on something? Ask the Deakin community for help.
</p>
{#if success} {#if success}
<p class="mb-6 text-sm text-secondary">{success}</p> <p class="text-secondary mb-6 text-sm">{success}</p>
{/if} {/if}
<form onsubmit={handleSubmit} class="space-y-6 border-t border-rule pt-8"> <form onsubmit={handleSubmit} class="border-rule space-y-6 border-t pt-8">
<div> <div>
<label for="title" class="kicker mb-2 block">Question title</label> <label for="title" class="kicker mb-2 block">Question title</label>
<input <input
@ -123,8 +127,7 @@
rows={10} rows={10}
placeholder="Describe your question in detail..." placeholder="Describe your question in detail..."
class="field resize-y" class="field resize-y"
required required></textarea>
></textarea>
</div> </div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
@ -153,25 +156,30 @@
type="button" type="button"
onclick={() => { onclick={() => {
useCustomUnit = !useCustomUnit; useCustomUnit = !useCustomUnit;
customUnit = ''; customUnit = "";
}} }}
class="mt-2 text-xs text-secondary hover:text-secondary-dark" class="text-secondary hover:text-secondary-dark mt-2 text-xs"
> >
{useCustomUnit ? 'Choose from list' : 'Other unit...'} {useCustomUnit ? "Choose from list" : "Other unit..."}
</button> </button>
{#if useCustomUnit} {#if useCustomUnit}
<input type="text" bind:value={customUnit} placeholder="e.g., SIT384" class="field mt-2" /> <input
type="text"
bind:value={customUnit}
placeholder="e.g., SIT384"
class="field mt-2"
/>
{/if} {/if}
</div> </div>
</div> </div>
{#if error} {#if error}
<p class="text-sm text-primary">{error}</p> <p class="text-primary text-sm">{error}</p>
{/if} {/if}
<button type="submit" disabled={loading} class="btn-primary"> <button type="submit" disabled={loading} class="btn-primary">
{loading ? 'Posting...' : 'Post question'} {loading ? "Posting..." : "Post question"}
</button> </button>
</form> </form>
</div> </div>

View file

@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import { query } from '$lib/api'; import { query } from "$lib/api";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import FeedRow from '$lib/components/FeedRow.svelte'; import FeedRow from "$lib/components/FeedRow.svelte";
import { timeAgo } from '$lib/time'; import { timeAgo } from "$lib/time";
import type { QuestionDoc, UnitDoc } from '$lib/types'; import type { QuestionDoc, UnitDoc } from "$lib/types";
let questions: (QuestionDoc & { unit?: UnitDoc })[] = $state([]); let questions: (QuestionDoc & { unit?: UnitDoc })[] = $state([]);
let units: UnitDoc[] = $state([]); let units: UnitDoc[] = $state([]);
let loading = $state(true); let loading = $state(true);
let selectedUnitId = $state(''); let selectedUnitId = $state("");
let sort = $state<'newest' | 'top'>('newest'); let sort = $state<"newest" | "top">("newest");
onMount(async () => { onMount(async () => {
const [q, u] = await Promise.all([query('questions:list', {}), query('units:getAll')]); const [q, u] = await Promise.all([query("questions:list", {}), query("units:getAll")]);
const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit])); const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit]));
questions = (q as QuestionDoc[]).map((question) => ({ questions = (q as QuestionDoc[]).map((question) => ({
...question, ...question,
unit: unitMap.get(question.unitId) unit: unitMap.get(question.unitId),
})); }));
units = u as UnitDoc[]; units = u as UnitDoc[];
loading = false; loading = false;
@ -28,8 +28,10 @@
}); });
const visible = $derived.by(() => { const visible = $derived.by(() => {
let list = selectedUnitId ? questions.filter((q) => q.unitId === selectedUnitId) : questions; let list = selectedUnitId
if (sort === 'top') list = [...list].sort((a, b) => b.voteCount - a.voteCount); ? questions.filter((q) => q.unitId === selectedUnitId)
: questions;
if (sort === "top") list = [...list].sort((a, b) => b.voteCount - a.voteCount);
return list; return list;
}); });
</script> </script>
@ -39,17 +41,17 @@
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
<div class="flex items-end justify-between gap-4 border-b border-rule pb-6"> <div class="border-rule flex items-end justify-between gap-4 border-b pb-6">
<h1 class="font-serif text-4xl font-medium text-ink">Questions</h1> <h1 class="text-ink font-serif text-4xl font-medium">Questions</h1>
<a href="/post/question" class="btn-primary">Ask a question</a> <a href="/post/question" class="btn-primary">Ask a question</a>
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-rule py-4"> <div class="border-rule flex flex-wrap items-center justify-between gap-4 border-b py-4">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
class="chip {selectedUnitId === '' ? 'chip-active' : ''}" class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
onclick={() => (selectedUnitId = '')} onclick={() => (selectedUnitId = "")}
> >
All units All units
</button> </button>
@ -67,14 +69,14 @@
<button <button
type="button" type="button"
class="kicker {sort === 'newest' ? 'text-ink' : ''}" class="kicker {sort === 'newest' ? 'text-ink' : ''}"
onclick={() => (sort = 'newest')} onclick={() => (sort = "newest")}
> >
Newest Newest
</button> </button>
<button <button
type="button" type="button"
class="kicker {sort === 'top' ? 'text-ink' : ''}" class="kicker {sort === 'top' ? 'text-ink' : ''}"
onclick={() => (sort = 'top')} onclick={() => (sort = "top")}
> >
Top Top
</button> </button>
@ -89,15 +91,21 @@
href="/questions/{question._id}" href="/questions/{question._id}"
title={question.title} title={question.title}
unitCode={question.unit?.code} unitCode={question.unit?.code}
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}" meta="{question.authorName} · {timeAgo(
question.createdAt,
)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
voteCount={question.voteCount} voteCount={question.voteCount}
targetType="question" targetType="question"
targetId={question._id} targetId={question._id}
/> />
{:else} {:else}
<div class="py-16"> <div class="py-16">
<p class="text-sm text-muted">No questions yet.</p> <p class="text-muted text-sm">No questions yet.</p>
<a href="/post/question" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Ask a question</a> <a
href="/post/question"
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
>Ask a question</a
>
</div> </div>
{/each} {/each}
{/if} {/if}

View file

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { query, mutation } from '$lib/api'; import { query, mutation } from "$lib/api";
import { isAuthenticated, getToken, currentUser } from '$lib/stores/auth'; import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth";
import { page } from '$app/state'; import { page } from "$app/state";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import VoteStack from '$lib/components/VoteStack.svelte'; import VoteStack from "$lib/components/VoteStack.svelte";
import { timeAgo } from '$lib/time'; import { timeAgo } from "$lib/time";
import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from '$lib/types'; import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
let question: QuestionDoc | null = $state(null); let question: QuestionDoc | null = $state(null);
let topic: TopicDoc | null = $state(null); let topic: TopicDoc | null = $state(null);
@ -15,13 +15,13 @@
let loading = $state(true); let loading = $state(true);
let isAuthor = $state(false); let isAuthor = $state(false);
let answerText = $state(''); let answerText = $state("");
let answerError = $state(''); let answerError = $state("");
let answerLoading = $state(false); let answerLoading = $state(false);
onMount(async () => { onMount(async () => {
const id = page.params.id; const id = page.params.id;
const result = await query('details:getQuestionWithDetails', { id }); const result = await query("details:getQuestionWithDetails", { id });
if (result) { if (result) {
question = result as QuestionDoc; question = result as QuestionDoc;
topic = (result as any).topic ?? null; topic = (result as any).topic ?? null;
@ -37,26 +37,26 @@
if (!answerText.trim()) return; if (!answerText.trim()) return;
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
answerError = 'Please sign in to answer'; answerError = "Please sign in to answer";
return; return;
} }
answerLoading = true; answerLoading = true;
answerError = ''; answerError = "";
try { try {
await mutation('comments:createOnQuestion', { await mutation("comments:createOnQuestion", {
token, token,
content: answerText.trim(), content: answerText.trim(),
questionId: page.params.id questionId: page.params.id,
}); });
answerText = ''; answerText = "";
const updated = await query('details:getQuestionWithDetails', { id: page.params.id }); const updated = await query("details:getQuestionWithDetails", { id: page.params.id });
if (updated) { if (updated) {
question = { ...(updated as any), answers: undefined } as QuestionDoc; question = { ...(updated as any), answers: undefined } as QuestionDoc;
answers = (updated as any).answers ?? []; answers = (updated as any).answers ?? [];
} }
} catch (err: any) { } catch (err: any) {
answerError = err.message ?? 'Failed to post answer'; answerError = err.message ?? "Failed to post answer";
} finally { } finally {
answerLoading = false; answerLoading = false;
} }
@ -66,16 +66,16 @@
const token = getToken(); const token = getToken();
if (!token || !question) return; if (!token || !question) return;
try { try {
await mutation('questions:markSolved', { token, id: question._id }); await mutation("questions:markSolved", { token, id: question._id });
question = { ...question, solved: true }; question = { ...question, solved: true };
} catch (err: any) { } catch (err: any) {
alert(err.message ?? 'Failed'); alert(err.message ?? "Failed");
} }
} }
</script> </script>
<svelte:head> <svelte:head>
<title>{question?.title ?? 'Loading...'} — Notebook</title> <title>{question?.title ?? "Loading..."} — Notebook</title>
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
@ -86,15 +86,24 @@
<VoteStack count={question.voteCount} targetType="question" targetId={question._id} /> <VoteStack count={question.voteCount} targetType="question" targetId={question._id} />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="kicker"> <p class="kicker">
{#if unit}{unit.code}{/if}{#if unit && topic} · {/if}{#if topic}{topic.name}{/if} {#if unit}{unit.code}{/if}{#if unit && topic}
{#if question.solved} · Solved{/if} ·
{/if}{#if topic}{topic.name}{/if}
{#if question.solved}
· Solved{/if}
</p> </p>
<h1 class="mt-2 font-serif text-3xl font-medium leading-tight text-ink sm:text-4xl">{question.title}</h1> <h1 class="text-ink mt-2 font-serif text-3xl leading-tight font-medium sm:text-4xl">
{question.title}
</h1>
<p class="kicker mt-3"> <p class="kicker mt-3">
{question.authorName} · {timeAgo(question.createdAt)} {question.authorName} · {timeAgo(question.createdAt)}
{#if isAuthor && !question.solved} {#if isAuthor && !question.solved}
· ·
<button type="button" onclick={markSolved} class="text-secondary hover:text-secondary-dark"> <button
type="button"
onclick={markSolved}
class="text-secondary hover:text-secondary-dark"
>
Mark as solved Mark as solved
</button> </button>
{/if} {/if}
@ -102,61 +111,82 @@
</div> </div>
</div> </div>
<div class="mt-8 whitespace-pre-wrap border-t border-rule pt-8 text-[15px] leading-relaxed text-ink"> <div
class="border-rule text-ink mt-8 border-t pt-8 text-[15px] leading-relaxed whitespace-pre-wrap"
>
{question.content} {question.content}
</div> </div>
<section class="mt-12 border-t border-rule pt-8"> <section class="border-rule mt-12 border-t pt-8">
<p class="kicker mb-6">Answers ({answers.length})</p> <p class="kicker mb-6">Answers ({answers.length})</p>
{#if get(isAuthenticated)} {#if get(isAuthenticated)}
<div class="mb-8"> <div class="mb-8">
<textarea bind:value={answerText} rows={4} placeholder="Write an answer..." class="field resize-y"></textarea> <textarea
bind:value={answerText}
rows={4}
placeholder="Write an answer..."
class="field resize-y"></textarea>
<div class="mt-3 flex items-center justify-between"> <div class="mt-3 flex items-center justify-between">
<span class="text-xs text-primary">{answerError}</span> <span class="text-primary text-xs">{answerError}</span>
<button <button
onclick={postAnswer} onclick={postAnswer}
disabled={answerLoading || !answerText.trim()} disabled={answerLoading || !answerText.trim()}
class="btn-primary" class="btn-primary"
> >
{answerLoading ? 'Posting...' : 'Post answer'} {answerLoading ? "Posting..." : "Post answer"}
</button> </button>
</div> </div>
</div> </div>
{:else} {:else}
<p class="mb-8 text-sm text-muted"> <p class="text-muted mb-8 text-sm">
<a href="/auth/login" class="text-secondary hover:text-secondary-dark">Sign in</a> to answer. <a href="/auth/login" class="text-secondary hover:text-secondary-dark"
>Sign in</a
> to answer.
</p> </p>
{/if} {/if}
<div> <div>
{#each answers as answer} {#each answers as answer}
<div class="border-t border-rule py-4"> <div class="border-rule border-t py-4">
<p class="kicker">{answer.authorName} · {timeAgo(answer.createdAt)}</p> <p class="kicker">{answer.authorName} · {timeAgo(answer.createdAt)}</p>
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">{answer.content}</p> <p class="text-ink mt-2 text-sm leading-relaxed whitespace-pre-wrap">
{answer.content}
</p>
{#if get(currentUser)?._id === answer.authorId} {#if get(currentUser)?._id === answer.authorId}
<button <button
onclick={async () => { onclick={async () => {
const token = getToken(); const token = getToken();
if (!token) return; if (!token) return;
try { try {
await mutation('comments:remove', { token, id: answer._id }); await mutation("comments:remove", {
const updated = await query('details:getQuestionWithDetails', { id: page.params.id }); token,
id: answer._id,
});
const updated = await query(
"details:getQuestionWithDetails",
{ id: page.params.id },
);
if (updated) answers = (updated as any).answers ?? []; if (updated) answers = (updated as any).answers ?? [];
} catch (e) {} } catch (e) {}
}} }}
class="mt-2 text-xs text-primary hover:text-primary-dark" class="text-primary hover:text-primary-dark mt-2 text-xs"
>Delete</button> >Delete</button
>
{/if} {/if}
</div> </div>
{:else} {:else}
<p class="text-sm text-muted">No answers yet.</p> <p class="text-muted text-sm">No answers yet.</p>
{/each} {/each}
</div> </div>
</section> </section>
{:else} {:else}
<h1 class="font-serif text-3xl text-ink">Question not found</h1> <h1 class="text-ink font-serif text-3xl">Question not found</h1>
<p class="mt-2 text-sm text-muted">This question may have been deleted or doesn't exist.</p> <p class="text-muted mt-2 text-sm">This question may have been deleted or doesn't exist.</p>
<a href="/questions" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Back to questions</a> <a
href="/questions"
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
>Back to questions</a
>
{/if} {/if}
</div> </div>

View file

@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import { query } from '$lib/api'; import { query } from "$lib/api";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import FeedRow from '$lib/components/FeedRow.svelte'; import FeedRow from "$lib/components/FeedRow.svelte";
import { timeAgo } from '$lib/time'; import { timeAgo } from "$lib/time";
import type { NoteDoc } from '$lib/types'; import type { NoteDoc } from "$lib/types";
let results: NoteDoc[] = $state([]); let results: NoteDoc[] = $state([]);
let loading = $state(true); let loading = $state(true);
let searchQuery = $state(''); let searchQuery = $state("");
let ranQuery = $state(false); let ranQuery = $state(false);
onMount(async () => { onMount(async () => {
searchQuery = new URL(window.location.href).searchParams.get('q') ?? ''; searchQuery = new URL(window.location.href).searchParams.get("q") ?? "";
if (searchQuery) { if (searchQuery) {
ranQuery = true; ranQuery = true;
results = (await query('notes:search', { query: searchQuery })) as NoteDoc[]; results = (await query("notes:search", { query: searchQuery })) as NoteDoc[];
} }
loading = false; loading = false;
}); });
</script> </script>
<svelte:head> <svelte:head>
<title>{searchQuery ? `Search: ${searchQuery}` : 'Search'} — Notebook</title> <title>{searchQuery ? `Search: ${searchQuery}` : "Search"} — Notebook</title>
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
<h1 class="font-serif text-4xl font-medium text-ink">Search</h1> <h1 class="text-ink font-serif text-4xl font-medium">Search</h1>
<form <form
class="mt-6 mb-8 border-b border-rule pb-8" class="border-rule mt-6 mb-8 border-b pb-8"
onsubmit={(e) => { onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
const q = searchQuery.trim(); const q = searchQuery.trim();
@ -36,7 +36,13 @@
}} }}
> >
<label for="q" class="kicker mb-2 block">Query</label> <label for="q" class="kicker mb-2 block">Query</label>
<input id="q" type="search" bind:value={searchQuery} placeholder="Search notes..." class="field" /> <input
id="q"
type="search"
bind:value={searchQuery}
placeholder="Search notes..."
class="field"
/>
</form> </form>
{#if loading} {#if loading}
@ -52,8 +58,12 @@
targetId={note._id} targetId={note._id}
/> />
{:else} {:else}
<p class="text-sm text-muted">No results found for “{searchQuery}”.</p> <p class="text-muted text-sm">No results found for “{searchQuery}”.</p>
<a href="/notes" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Browse notes</a> <a
href="/notes"
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
>Browse notes</a
>
{/each} {/each}
{/if} {/if}
</div> </div>

View file

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { query } from '$lib/api'; import { query } from "$lib/api";
import { page } from '$app/state'; import { page } from "$app/state";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import FeedRow from '$lib/components/FeedRow.svelte'; import FeedRow from "$lib/components/FeedRow.svelte";
import { timeAgo } from '$lib/time'; import { timeAgo } from "$lib/time";
import type { NoteDoc, QuestionDoc, TopicDoc } from '$lib/types'; import type { NoteDoc, QuestionDoc, TopicDoc } from "$lib/types";
let topic: TopicDoc | null = $state(null); let topic: TopicDoc | null = $state(null);
let notes: NoteDoc[] = $state([]); let notes: NoteDoc[] = $state([]);
@ -13,12 +13,12 @@
onMount(async () => { onMount(async () => {
const slug = page.params.slug; const slug = page.params.slug;
const t = await query('topics:getBySlug', { slug }); const t = await query("topics:getBySlug", { slug });
topic = t as TopicDoc; topic = t as TopicDoc;
if (topic) { if (topic) {
const [n, q] = await Promise.all([ const [n, q] = await Promise.all([
query('notes:list', { topicId: topic._id }), query("notes:list", { topicId: topic._id }),
query('questions:list', { topicId: topic._id }) query("questions:list", { topicId: topic._id }),
]); ]);
notes = n as NoteDoc[]; notes = n as NoteDoc[];
questions = q as QuestionDoc[]; questions = q as QuestionDoc[];
@ -28,7 +28,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{topic?.name ?? 'Topic'} — Notebook</title> <title>{topic?.name ?? "Topic"} — Notebook</title>
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
@ -36,18 +36,20 @@
<p class="kicker py-16">Loading</p> <p class="kicker py-16">Loading</p>
{:else if topic} {:else if topic}
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Topic</p> <p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Topic</p>
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">{topic.name}</h1> <h1 class="text-ink mt-2 font-serif text-4xl font-medium">{topic.name}</h1>
{#if topic.description} {#if topic.description}
<p class="mt-2 text-[15px] text-muted">{topic.description}</p> <p class="text-muted mt-2 text-[15px]">{topic.description}</p>
{/if} {/if}
{#if notes.length > 0} {#if notes.length > 0}
<p class="kicker mt-10 border-t border-rule pt-8">Notes</p> <p class="kicker border-rule mt-10 border-t pt-8">Notes</p>
{#each notes as note} {#each notes as note}
<FeedRow <FeedRow
href="/notes/{note._id}" href="/notes/{note._id}"
title={note.title} title={note.title}
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}" meta="{note.authorName} · {timeAgo(
note.createdAt,
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
voteCount={note.voteCount} voteCount={note.voteCount}
targetType="note" targetType="note"
targetId={note._id} targetId={note._id}
@ -56,12 +58,14 @@
{/if} {/if}
{#if questions.length > 0} {#if questions.length > 0}
<p class="kicker mt-10 border-t border-rule pt-8">Questions</p> <p class="kicker border-rule mt-10 border-t pt-8">Questions</p>
{#each questions as question} {#each questions as question}
<FeedRow <FeedRow
href="/questions/{question._id}" href="/questions/{question._id}"
title={question.title} title={question.title}
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}" meta="{question.authorName} · {timeAgo(
question.createdAt,
)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
voteCount={question.voteCount} voteCount={question.voteCount}
targetType="question" targetType="question"
targetId={question._id} targetId={question._id}
@ -70,14 +74,20 @@
{/if} {/if}
{#if notes.length === 0 && questions.length === 0} {#if notes.length === 0 && questions.length === 0}
<p class="mt-10 text-sm text-muted">No content in this topic yet.</p> <p class="text-muted mt-10 text-sm">No content in this topic yet.</p>
<div class="mt-4 flex gap-4"> <div class="mt-4 flex gap-4">
<a href="/post/note" class="text-sm text-secondary hover:text-secondary-dark">Post a note</a> <a href="/post/note" class="text-secondary hover:text-secondary-dark text-sm"
<a href="/post/question" class="text-sm text-secondary hover:text-secondary-dark">Ask a question</a> >Post a note</a
>
<a href="/post/question" class="text-secondary hover:text-secondary-dark text-sm"
>Ask a question</a
>
</div> </div>
{/if} {/if}
{:else} {:else}
<h1 class="font-serif text-3xl text-ink">Topic not found</h1> <h1 class="text-ink font-serif text-3xl">Topic not found</h1>
<a href="/" class="mt-2 inline-block text-sm text-secondary hover:text-secondary-dark">Home</a> <a href="/" class="text-secondary hover:text-secondary-dark mt-2 inline-block text-sm"
>Home</a
>
{/if} {/if}
</div> </div>

View file

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { query } from '$lib/api'; import { query } from "$lib/api";
import { page } from '$app/state'; import { page } from "$app/state";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import FeedRow from '$lib/components/FeedRow.svelte'; import FeedRow from "$lib/components/FeedRow.svelte";
import { timeAgo } from '$lib/time'; import { timeAgo } from "$lib/time";
import type { NoteDoc, QuestionDoc, UnitDoc } from '$lib/types'; import type { NoteDoc, QuestionDoc, UnitDoc } from "$lib/types";
let unit: UnitDoc | null = $state(null); let unit: UnitDoc | null = $state(null);
let notes: NoteDoc[] = $state([]); let notes: NoteDoc[] = $state([]);
@ -13,12 +13,12 @@
onMount(async () => { onMount(async () => {
const code = page.params.code; const code = page.params.code;
const u = await query('units:getByCode', { code }); const u = await query("units:getByCode", { code });
unit = u as UnitDoc; unit = u as UnitDoc;
if (unit) { if (unit) {
const [n, q] = await Promise.all([ const [n, q] = await Promise.all([
query('notes:list', { unitId: unit._id }), query("notes:list", { unitId: unit._id }),
query('questions:list', { unitId: unit._id }) query("questions:list", { unitId: unit._id }),
]); ]);
notes = n as NoteDoc[]; notes = n as NoteDoc[];
questions = q as QuestionDoc[]; questions = q as QuestionDoc[];
@ -28,7 +28,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{unit?.code ?? 'Unit'} — Notebook</title> <title>{unit?.code ?? "Unit"} — Notebook</title>
</svelte:head> </svelte:head>
<div class="page"> <div class="page">
@ -36,20 +36,22 @@
<p class="kicker py-16">Loading</p> <p class="kicker py-16">Loading</p>
{:else if unit} {:else if unit}
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Unit</p> <p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Unit</p>
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">{unit.code}</h1> <h1 class="text-ink mt-2 font-serif text-4xl font-medium">{unit.code}</h1>
<p class="mt-1 text-[15px] text-muted">{unit.name}</p> <p class="text-muted mt-1 text-[15px]">{unit.name}</p>
{#if unit.description} {#if unit.description}
<p class="mt-2 text-sm text-muted">{unit.description}</p> <p class="text-muted mt-2 text-sm">{unit.description}</p>
{/if} {/if}
{#if notes.length > 0} {#if notes.length > 0}
<p class="kicker mt-10 border-t border-rule pt-8">Notes</p> <p class="kicker border-rule mt-10 border-t pt-8">Notes</p>
{#each notes as note} {#each notes as note}
<FeedRow <FeedRow
href="/notes/{note._id}" href="/notes/{note._id}"
title={note.title} title={note.title}
unitCode={unit.code} unitCode={unit.code}
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}" meta="{note.authorName} · {timeAgo(
note.createdAt,
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
voteCount={note.voteCount} voteCount={note.voteCount}
targetType="note" targetType="note"
targetId={note._id} targetId={note._id}
@ -58,13 +60,15 @@
{/if} {/if}
{#if questions.length > 0} {#if questions.length > 0}
<p class="kicker mt-10 border-t border-rule pt-8">Questions</p> <p class="kicker border-rule mt-10 border-t pt-8">Questions</p>
{#each questions as question} {#each questions as question}
<FeedRow <FeedRow
href="/questions/{question._id}" href="/questions/{question._id}"
title={question.title} title={question.title}
unitCode={unit.code} unitCode={unit.code}
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}" meta="{question.authorName} · {timeAgo(
question.createdAt,
)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
voteCount={question.voteCount} voteCount={question.voteCount}
targetType="question" targetType="question"
targetId={question._id} targetId={question._id}
@ -73,14 +77,20 @@
{/if} {/if}
{#if notes.length === 0 && questions.length === 0} {#if notes.length === 0 && questions.length === 0}
<p class="mt-10 text-sm text-muted">No content for this unit yet.</p> <p class="text-muted mt-10 text-sm">No content for this unit yet.</p>
<div class="mt-4 flex gap-4"> <div class="mt-4 flex gap-4">
<a href="/post/note" class="text-sm text-secondary hover:text-secondary-dark">Post a note</a> <a href="/post/note" class="text-secondary hover:text-secondary-dark text-sm"
<a href="/post/question" class="text-sm text-secondary hover:text-secondary-dark">Ask a question</a> >Post a note</a
>
<a href="/post/question" class="text-secondary hover:text-secondary-dark text-sm"
>Ask a question</a
>
</div> </div>
{/if} {/if}
{:else} {:else}
<h1 class="font-serif text-3xl text-ink">Unit not found</h1> <h1 class="text-ink font-serif text-3xl">Unit not found</h1>
<a href="/" class="mt-2 inline-block text-sm text-secondary hover:text-secondary-dark">Home</a> <a href="/" class="text-secondary hover:text-secondary-dark mt-2 inline-block text-sm"
>Home</a
>
{/if} {/if}
</div> </div>

View file

@ -1,8 +1,8 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from 'vitest/config'; import { defineConfig } from "vitest/config";
import { playwright } from '@vitest/browser-playwright'; import { playwright } from "@vitest/browser-playwright";
import adapter from '@sveltejs/adapter-node'; import adapter from "@sveltejs/adapter-node";
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from "@sveltejs/kit/vite";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@ -10,39 +10,40 @@ export default defineConfig({
sveltekit({ sveltekit({
compilerOptions: { compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6. // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true runes: ({ filename }) =>
filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
}, },
// adapter-node is used because the app persists data to a local SQLite file on the server. // adapter-node is used because the app persists data to a local SQLite file on the server.
adapter: adapter() adapter: adapter(),
}) }),
], ],
test: { test: {
expect: { requireAssertions: true }, expect: { requireAssertions: true },
projects: [ projects: [
{ {
extends: './vite.config.ts', extends: "./vite.config.ts",
test: { test: {
name: 'client', name: "client",
browser: { browser: {
enabled: true, enabled: true,
provider: playwright(), provider: playwright(),
instances: [{ browser: 'chromium', headless: true }] instances: [{ browser: "chromium", headless: true }],
},
include: ["src/**/*.svelte.{test,spec}.{js,ts}"],
exclude: ["src/lib/server/**"],
}, },
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']
}
}, },
{ {
extends: './vite.config.ts', extends: "./vite.config.ts",
test: { test: {
name: 'server', name: "server",
environment: 'node', environment: "node",
include: ['src/**/*.{test,spec}.{js,ts}'], include: ["src/**/*.{test,spec}.{js,ts}"],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"],
} },
} },
] ],
} },
}); });