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

View file

@ -1,31 +1,31 @@
name: Deploy to VPS
on:
push:
branches: [main]
workflow_dispatch:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
deploy:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and start containers
run: docker compose up -d --build --remove-orphans
- name: Build and start containers
run: docker compose up -d --build --remove-orphans
- name: Prune dangling images
run: docker image prune -f
- name: Prune dangling images
run: docker image prune -f
- name: Health check
run: |
for i in $(seq 1 30); do
if curl -fsS http://localhost:4073 > /dev/null; then
echo "App is up"
exit 0
fi
sleep 2
done
echo "App did not become healthy" >&2
exit 1
- name: Health check
run: |
for i in $(seq 1 30); do
if curl -fsS http://localhost:4073 > /dev/null; then
echo "App is up"
exit 0
fi
sleep 2
done
echo "App did not become healthy" >&2
exit 1

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": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
}

View file

@ -33,23 +33,23 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
1. Clone the repository and install dependencies:
```sh
npm install
```
```sh
npm install
```
2. Configure environment variables (optional):
```sh
cp .env .env.local
```
```sh
cp .env .env.local
```
The only variable is `DATABASE_PATH`, which defaults to `data/dsec.db`.
The only variable is `DATABASE_PATH`, which defaults to `data/dsec.db`.
3. Start the development server:
```sh
npm run dev
```
```sh
npm run dev
```
4. Open the URL printed in the terminal (usually `http://localhost:5173`).

View file

@ -1,12 +1,12 @@
services:
app:
build: .
ports:
- "4073:3000"
environment:
DATABASE_PATH: data/dsec.db
volumes:
- dsec-data:/app/data
app:
build: .
ports:
- "4073:3000"
environment:
DATABASE_PATH: data/dsec.db
volumes:
- dsec-data:/app/data
volumes:
dsec-data:
dsec-data:

416
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.3.0",
"@vitest/browser-playwright": "^4.1.8",
"oxfmt": "^0.65.0",
"playwright": "^1.60.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
@ -111,6 +112,353 @@
"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": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -929,9 +1277,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.70.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz",
"integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==",
"version": "2.70.3",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz",
"integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2164,6 +2512,58 @@
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -2571,6 +2971,16 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",

View file

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

10
src/app.d.ts vendored
View file

@ -1,9 +1,9 @@
export {};
declare global {
namespace App {
interface PageData {}
interface PageState {}
interface Platform {}
}
namespace App {
interface PageData {}
interface PageState {}
interface Platform {}
}
}

View file

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

View file

@ -1,5 +1,5 @@
<script lang="ts">
import VoteStack from './VoteStack.svelte';
import VoteStack from "./VoteStack.svelte";
let {
href,
@ -8,25 +8,27 @@
meta,
voteCount = 0,
targetType,
targetId
targetId,
}: {
href: string;
title: string;
unitCode?: string;
meta: string;
voteCount?: number;
targetType: 'note' | 'question';
targetType: "note" | "question";
targetId: string;
} = $props();
</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} />
<a {href} class="min-w-0 flex-1 group">
<a {href} class="group min-w-0 flex-1">
{#if unitCode}
<p class="kicker">{unitCode}</p>
{/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}
</h3>
<p class="kicker mt-1.5">{meta}</p>

View file

@ -1,76 +1,92 @@
<script lang="ts">
import { isAuthenticated, initAuth, logout } from '$lib/stores/auth';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { isAuthenticated, initAuth, logout } from "$lib/stores/auth";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let mobileMenuOpen = $state(false);
let auth = $state(false);
let mobileMenuOpen = $state(false);
let auth = $state(false);
onMount(() => {
initAuth();
const unsub = isAuthenticated.subscribe((v) => (auth = v));
return () => unsub();
});
onMount(() => {
initAuth();
const unsub = isAuthenticated.subscribe((v) => (auth = v));
return () => unsub();
});
const path = $derived(page.url.pathname);
const onNotes = $derived(path === '/notes' || path.startsWith('/notes/'));
const onQuestions = $derived(path === '/questions' || path.startsWith('/questions/'));
const path = $derived(page.url.pathname);
const onNotes = $derived(path === "/notes" || path.startsWith("/notes/"));
const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/"));
</script>
<header class="border-b border-rule bg-white">
<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>
<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">
<a href="/" class="text-ink font-serif text-[1.65rem] leading-none">Notebook</a>
<nav class="hidden items-center gap-8 md:flex">
<a href="/notes" class="nav-link {onNotes ? 'nav-link-active' : ''}">Notes</a>
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}">Questions</a>
{#if auth}
<button
type="button"
class="nav-link"
onclick={() => {
logout();
goto('/');
}}
>
Sign out
</button>
{:else}
<a href="/auth/login" class="nav-link">Sign in</a>
{/if}
</nav>
<nav class="hidden items-center gap-8 md:flex">
<a href="/notes" class="nav-link {onNotes ? 'nav-link-active' : ''}">Notes</a>
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}"
>Questions</a
>
{#if auth}
<button
type="button"
class="nav-link"
onclick={() => {
logout();
goto("/");
}}
>
Sign out
</button>
{:else}
<a href="/auth/login" class="nav-link">Sign in</a>
{/if}
</nav>
<button
class="p-1 text-muted hover:text-ink md:hidden"
aria-label="Open menu"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
>
<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" />
</svg>
</button>
</div>
<button
class="text-muted hover:text-ink p-1 md:hidden"
aria-label="Open menu"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
>
<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" />
</svg>
</button>
</div>
{#if mobileMenuOpen}
<nav class="space-y-3 border-t border-rule px-4 py-4 md:hidden">
<a href="/notes" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Notes</a>
<a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Questions</a>
{#if auth}
<button
type="button"
class="nav-link block"
onclick={() => {
logout();
goto('/');
mobileMenuOpen = false;
}}
>
Sign out
</button>
{:else}
<a href="/auth/login" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Sign in</a>
{/if}
</nav>
{/if}
{#if mobileMenuOpen}
<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="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
>Questions</a
>
{#if auth}
<button
type="button"
class="nav-link block"
onclick={() => {
logout();
goto("/");
mobileMenuOpen = false;
}}
>
Sign out
</button>
{:else}
<a
href="/auth/login"
class="nav-link block"
onclick={() => (mobileMenuOpen = false)}>Sign in</a
>
{/if}
</nav>
{/if}
</header>

View file

@ -1,15 +1,15 @@
<script lang="ts">
import { mutation } from '$lib/api';
import { getToken } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { mutation } from "$lib/api";
import { getToken } from "$lib/stores/auth";
import { goto } from "$app/navigation";
let {
count = 0,
targetType,
targetId
targetId,
}: {
count?: number;
targetType: 'note' | 'question';
targetType: "note" | "question";
targetId: string;
} = $props();
@ -23,13 +23,13 @@
e.stopPropagation();
const token = getToken();
if (!token) {
goto('/auth/login');
goto("/auth/login");
return;
}
if (busy) return;
busy = true;
try {
const result = await mutation('votes:cast', { token, targetType, targetId, value });
const result = await mutation("votes:cast", { token, targetType, targetId, value });
localCount = result.voteCount;
userVote = result.userVote;
} catch {
@ -43,30 +43,46 @@
<div class="flex w-8 shrink-0 flex-col gap-1">
<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'
: ''}"
onclick={(e) => vote(1, e)}
aria-label="Upvote"
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" />
</svg>
</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}
</div>
<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'
: ''}"
onclick={(e) => vote(-1, e)}
aria-label="Downvote"
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" />
</svg>
</button>

View file

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

View file

@ -1,14 +1,14 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
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;
function createSchema(database: DatabaseSync) {
database.exec(`
database.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
@ -92,82 +92,138 @@ function createSchema(database: DatabaseSync) {
}
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: 'Networking', slug: 'networking', description: 'Computer networks, protocols, and architectures' },
{ 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' },
{
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: "Networking",
slug: "networking",
description: "Computer networks, protocols, and architectures",
},
{
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 = [
{ code: 'SIT102', name: 'Introduction to Programming' },
{ code: 'SIT111', name: 'Computer Systems' },
{ code: 'SIT192', name: 'Discrete Mathematics' },
{ code: 'SIT202', name: 'Computer Networks and Communication' },
{ code: 'SIT210', name: 'Embedded Systems Development' },
{ code: 'SIT221', name: 'Data Structures and Algorithms' },
{ code: 'SIT232', name: 'Object-Oriented Development' },
{ code: 'SIT281', name: 'Cryptography' },
{ code: 'SIT282', name: 'Computer Forensics' },
{ code: 'SIT283', name: 'Ethical Hacking' },
{ code: 'SIT284', name: 'Cyber Security Management' },
{ code: 'SIT313', name: 'Full Stack Web Development' },
{ code: 'SIT315', name: 'Programming Paradigms' },
{ code: 'SIT323', name: 'Cloud Native Application Development' },
{ code: 'SIT331', name: 'IT Security' },
{ code: 'SIT374', name: 'Team Project (A) - Project Management' },
{ code: 'SIT378', name: 'Team Project (B) - Execution' },
{ code: 'SIT379', name: 'Ethical Hacking' },
{ code: 'SIT384', name: 'Cyber Security Analytics' },
{ code: 'SIT393', name: 'Computing Internship' },
{ code: 'MIS771', name: 'Business Intelligence and Data Warehousing' },
{ code: 'MIS772', name: 'Predictive Analytics' },
{ code: 'MIS782', name: 'Information Security Governance' },
{ code: 'MIS784', name: 'Cyber Security Management and Practices' },
{ code: 'MIS785', name: 'IT Strategy and Governance' },
{ code: 'MIS798', name: 'Business Process Management' },
{ code: "SIT102", name: "Introduction to Programming" },
{ code: "SIT111", name: "Computer Systems" },
{ code: "SIT192", name: "Discrete Mathematics" },
{ code: "SIT202", name: "Computer Networks and Communication" },
{ code: "SIT210", name: "Embedded Systems Development" },
{ code: "SIT221", name: "Data Structures and Algorithms" },
{ code: "SIT232", name: "Object-Oriented Development" },
{ code: "SIT281", name: "Cryptography" },
{ code: "SIT282", name: "Computer Forensics" },
{ code: "SIT283", name: "Ethical Hacking" },
{ code: "SIT284", name: "Cyber Security Management" },
{ code: "SIT313", name: "Full Stack Web Development" },
{ code: "SIT315", name: "Programming Paradigms" },
{ code: "SIT323", name: "Cloud Native Application Development" },
{ code: "SIT331", name: "IT Security" },
{ code: "SIT374", name: "Team Project (A) - Project Management" },
{ code: "SIT378", name: "Team Project (B) - Execution" },
{ code: "SIT379", name: "Ethical Hacking" },
{ code: "SIT384", name: "Cyber Security Analytics" },
{ code: "SIT393", name: "Computing Internship" },
{ code: "MIS771", name: "Business Intelligence and Data Warehousing" },
{ code: "MIS772", name: "Predictive Analytics" },
{ code: "MIS782", name: "Information Security Governance" },
{ code: "MIS784", name: "Cyber Security Management and Practices" },
{ code: "MIS785", name: "IT Strategy and Governance" },
{ code: "MIS798", name: "Business Process Management" },
];
function seed(database: DatabaseSync) {
const existing = database.prepare('SELECT COUNT(*) AS c FROM topics').get() as { c: number };
if (existing.c > 0) return;
const existing = database.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number };
if (existing.c > 0) return;
const insertTopic = database.prepare(
'INSERT INTO topics (id, name, slug, description) VALUES (?, ?, ?, ?)'
);
const insertUnit = database.prepare('INSERT INTO units (id, code, name) VALUES (?, ?, ?)');
const insertTopic = database.prepare(
"INSERT INTO topics (id, name, slug, description) VALUES (?, ?, ?, ?)",
);
const insertUnit = database.prepare("INSERT INTO units (id, code, name) VALUES (?, ?, ?)");
database.exec('BEGIN');
try {
for (const topic of SEED_TOPICS) {
insertTopic.run(randomUUID(), topic.name, topic.slug, topic.description ?? null);
}
for (const unit of SEED_UNITS) {
insertUnit.run(randomUUID(), unit.code, unit.name);
}
database.exec('COMMIT');
} catch (err) {
database.exec('ROLLBACK');
throw err;
}
database.exec("BEGIN");
try {
for (const topic of SEED_TOPICS) {
insertTopic.run(randomUUID(), topic.name, topic.slug, topic.description ?? null);
}
for (const unit of SEED_UNITS) {
insertUnit.run(randomUUID(), unit.code, unit.name);
}
database.exec("COMMIT");
} catch (err) {
database.exec("ROLLBACK");
throw err;
}
}
export function getDb(): DatabaseSync {
if (!db) {
mkdirSync(dirname(DB_PATH), { recursive: true });
db = new DatabaseSync(DB_PATH);
createSchema(db);
seed(db);
}
return db;
if (!db) {
mkdirSync(dirname(DB_PATH), { recursive: true });
db = new DatabaseSync(DB_PATH);
createSchema(db);
seed(db);
}
return db;
}

View file

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

View file

@ -1,50 +1,56 @@
import { writable } from 'svelte/store';
import { query, mutation } from '$lib/api';
import type { UserDoc } from '$lib/types';
import { writable } from "svelte/store";
import { query, mutation } from "$lib/api";
import type { UserDoc } from "$lib/types";
export const currentUser = writable<UserDoc | null>(null);
export const isAuthenticated = writable(false);
const STORAGE_KEY = 'dsec_session';
const STORAGE_KEY = "dsec_session";
export async function initAuth() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const { token } = JSON.parse(stored);
const user = await query('users:getByToken', { token });
if (user) {
currentUser.set(user as UserDoc);
isAuthenticated.set(true);
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const { token } = JSON.parse(stored);
const user = await query("users:getByToken", { token });
if (user) {
currentUser.set(user as UserDoc);
isAuthenticated.set(true);
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
}
export async function login(email: string, name: string) {
const result = await mutation('users:register', { email, name });
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);
isAuthenticated.set(true);
return result;
const result = await mutation("users:register", { email, name });
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);
isAuthenticated.set(true);
return result;
}
export function getToken(): string | null {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
try {
return JSON.parse(stored).token;
} catch {
return null;
}
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
try {
return JSON.parse(stored).token;
} catch {
return null;
}
}
export function logout() {
localStorage.removeItem(STORAGE_KEY);
currentUser.set(null);
isAuthenticated.set(false);
localStorage.removeItem(STORAGE_KEY);
currentUser.set(null);
isAuthenticated.set(false);
}

View file

@ -1,7 +1,7 @@
export function timeAgo(ts: number): string {
const diff = Date.now() - ts;
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`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}H AGO`;

View file

@ -1,61 +1,61 @@
export type Id<T extends string> = string & { __table: T };
export type Doc<T extends string> = {
_id: Id<T>;
_creationTime: number;
_id: Id<T>;
_creationTime: number;
};
export type UserDoc = Doc<"users"> & {
email: string;
name: string;
sessionToken?: string;
email: string;
name: string;
sessionToken?: string;
};
export type TopicDoc = Doc<"topics"> & {
name: string;
slug: string;
description?: string;
name: string;
slug: string;
description?: string;
};
export type UnitDoc = Doc<"units"> & {
code: string;
name: string;
description?: string;
code: string;
name: string;
description?: string;
};
export type NoteDoc = Doc<"notes"> & {
title: string;
content: string;
topicId: Id<"topics">;
unitId: Id<"units">;
authorId: Id<"users">;
authorName: string;
createdAt: number;
updatedAt: number;
voteCount: number;
commentCount: number;
title: string;
content: string;
topicId: Id<"topics">;
unitId: Id<"units">;
authorId: Id<"users">;
authorName: string;
createdAt: number;
updatedAt: number;
voteCount: number;
commentCount: number;
};
export type QuestionDoc = Doc<"questions"> & {
title: string;
content: string;
topicId: Id<"topics">;
unitId: Id<"units">;
authorId: Id<"users">;
authorName: string;
createdAt: number;
updatedAt: number;
voteCount: number;
answerCount: number;
solved: boolean;
title: string;
content: string;
topicId: Id<"topics">;
unitId: Id<"units">;
authorId: Id<"users">;
authorName: string;
createdAt: number;
updatedAt: number;
voteCount: number;
answerCount: number;
solved: boolean;
};
export type CommentDoc = Doc<"comments"> & {
content: string;
authorId: Id<"users">;
authorName: string;
parentId?: Id<"notes">;
questionId?: Id<"questions">;
parentCommentId?: Id<"comments">;
createdAt: number;
content: string;
authorId: Id<"users">;
authorName: string;
parentId?: Id<"notes">;
questionId?: Id<"questions">;
parentCommentId?: Id<"comments">;
createdAt: number;
};

View file

@ -1,36 +1,39 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import { initAuth } from '$lib/stores/auth';
import { onMount } from 'svelte';
import './layout.css';
import Navbar from "$lib/components/Navbar.svelte";
import { initAuth } from "$lib/stores/auth";
import { onMount } from "svelte";
import "./layout.css";
let { children } = $props();
let { children } = $props();
onMount(() => {
initAuth();
});
onMount(() => {
initAuth();
});
</script>
<svelte:head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" />
</svelte:head>
<div class="flex min-h-screen flex-col bg-white">
<Navbar />
<main class="flex-1">
{@render children()}
</main>
<footer class="mt-16 border-t border-rule">
<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>
<p class="kicker">Notebook — written by students, for students.</p>
<p class="mt-2 text-xs text-faint">
DSEC Notebook is a community resource for Deakin University students. Not affiliated with Deakin University.
</p>
</div>
<a href="/search" class="kicker hover:text-primary">Search</a>
</div>
</footer>
<Navbar />
<main class="flex-1">
{@render children()}
</main>
<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>
<p class="kicker">Notebook — written by students, for students.</p>
<p class="text-faint mt-2 text-xs">
DSEC Notebook is a community resource for Deakin University students. Not
affiliated with Deakin University.
</p>
</div>
<a href="/search" class="kicker hover:text-primary">Search</a>
</div>
</footer>
</div>

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
@import 'tailwindcss';
@import "tailwindcss";
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'EB Garamond', 'Iowan Old Style', Palatino, Georgia, serif;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "EB Garamond", "Iowan Old Style", Palatino, Georgia, serif;
--color-primary: #ea4c65;
--color-primary-dark: #d43d56;
--color-secondary: #00bcd4;
@ -25,35 +25,35 @@ html {
}
.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 {
@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 {
@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 {
@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 {
@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 {
@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 {
@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 {
@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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
export default defineConfig({
plugins: [
@ -10,39 +10,40 @@ export default defineConfig({
sveltekit({
compilerOptions: {
// 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: adapter()
})
adapter: adapter(),
}),
],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
extends: "./vite.config.ts",
test: {
name: 'client',
name: "client",
browser: {
enabled: true,
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: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
name: "server",
environment: "node",
include: ["src/**/*.{test,spec}.{js,ts}"],
exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"],
},
},
],
},
});