mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
added formatter
This commit is contained in:
parent
b7ef043e6c
commit
ffa6b84da3
33 changed files with 2398 additions and 1700 deletions
46
.github/workflows/deploy.yml
vendored
46
.github/workflows/deploy.yml
vendored
|
|
@ -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
8
.oxfmtrc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": [],
|
||||
"sortTailwindcss": true,
|
||||
"useTabs": true,
|
||||
"tabWidth": 4,
|
||||
"svelte": true
|
||||
}
|
||||
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
|
|
@ -1,6 +1,3 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
416
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
10
src/app.d.ts
vendored
|
|
@ -1,9 +1,9 @@
|
|||
export {};
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface PageData {}
|
||||
interface PageState {}
|
||||
interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
interface PageData {}
|
||||
interface PageState {}
|
||||
interface Platform {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ?? {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue