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
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": [
|
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
|
||||||
"svelte.svelte-vscode",
|
|
||||||
"bradlc.vscode-tailwindcss"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
416
package-lock.json
generated
416
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/node": "^26.3.0",
|
"@types/node": "^26.3.0",
|
||||||
"@vitest/browser-playwright": "^4.1.8",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
|
"oxfmt": "^0.65.0",
|
||||||
"playwright": "^1.60.0",
|
"playwright": "^1.60.0",
|
||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
|
|
@ -111,6 +112,353 @@
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@oxfmt/binding-android-arm-eabi": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-android-arm64": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-darwin-arm64": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-darwin-x64": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-freebsd-x64": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-arm-musleabihf": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-arm64-gnu": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-arm64-musl": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-ppc64-gnu": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-riscv64-gnu": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-riscv64-musl": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-s390x-gnu": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-x64-gnu": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-linux-x64-musl": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-openharmony-arm64": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-win32-arm64-msvc": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-win32-ia32-msvc": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxfmt/binding-win32-x64-msvc": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
|
|
@ -929,9 +1277,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/kit": {
|
"node_modules/@sveltejs/kit": {
|
||||||
"version": "2.70.2",
|
"version": "2.70.3",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz",
|
||||||
"integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==",
|
"integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2164,6 +2512,58 @@
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oxfmt": {
|
||||||
|
"version": "0.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.65.0.tgz",
|
||||||
|
"integrity": "sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinypool": "2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"oxfmt": "bin/oxfmt"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@oxfmt/binding-android-arm-eabi": "0.65.0",
|
||||||
|
"@oxfmt/binding-android-arm64": "0.65.0",
|
||||||
|
"@oxfmt/binding-darwin-arm64": "0.65.0",
|
||||||
|
"@oxfmt/binding-darwin-x64": "0.65.0",
|
||||||
|
"@oxfmt/binding-freebsd-x64": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-arm-gnueabihf": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-arm-musleabihf": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-arm64-gnu": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-arm64-musl": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-ppc64-gnu": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-riscv64-gnu": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-riscv64-musl": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-s390x-gnu": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-x64-gnu": "0.65.0",
|
||||||
|
"@oxfmt/binding-linux-x64-musl": "0.65.0",
|
||||||
|
"@oxfmt/binding-openharmony-arm64": "0.65.0",
|
||||||
|
"@oxfmt/binding-win32-arm64-msvc": "0.65.0",
|
||||||
|
"@oxfmt/binding-win32-ia32-msvc": "0.65.0",
|
||||||
|
"@oxfmt/binding-win32-x64-msvc": "0.65.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"vite-plus": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite-plus": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
|
|
@ -2571,6 +2971,16 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinypool": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || >=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyrainbow": {
|
"node_modules/tinyrainbow": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "dsec-notebook",
|
"name": "dsec-notebook",
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|
@ -11,7 +11,9 @@
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run"
|
"test": "npm run test:unit -- --run",
|
||||||
|
"fmt": "oxfmt",
|
||||||
|
"fmt:check": "oxfmt --check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
|
|
@ -20,6 +22,7 @@
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/node": "^26.3.0",
|
"@types/node": "^26.3.0",
|
||||||
"@vitest/browser-playwright": "^4.1.8",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
|
"oxfmt": "^0.65.0",
|
||||||
"playwright": "^1.60.0",
|
"playwright": "^1.60.0",
|
||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"svelte-check": "^4.6.0",
|
"svelte-check": "^4.6.0",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
async function call(name: string, args: Record<string, any> = {}): Promise<any> {
|
async function call(name: string, args: Record<string, any> = {}): Promise<any> {
|
||||||
const res = await fetch('/api', {
|
const res = await fetch("/api", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ fn: name, args })
|
body: JSON.stringify({ fn: name, args }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
throw new Error(data.error ?? 'Request failed');
|
throw new Error(data.error ?? "Request failed");
|
||||||
}
|
}
|
||||||
return data.result;
|
return data.result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import VoteStack from './VoteStack.svelte';
|
import VoteStack from "./VoteStack.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
href,
|
href,
|
||||||
|
|
@ -8,25 +8,27 @@
|
||||||
meta,
|
meta,
|
||||||
voteCount = 0,
|
voteCount = 0,
|
||||||
targetType,
|
targetType,
|
||||||
targetId
|
targetId,
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
title: string;
|
title: string;
|
||||||
unitCode?: string;
|
unitCode?: string;
|
||||||
meta: string;
|
meta: string;
|
||||||
voteCount?: number;
|
voteCount?: number;
|
||||||
targetType: 'note' | 'question';
|
targetType: "note" | "question";
|
||||||
targetId: string;
|
targetId: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="flex gap-4 border-b border-rule py-5">
|
<article class="border-rule flex gap-4 border-b py-5">
|
||||||
<VoteStack count={voteCount} {targetType} {targetId} />
|
<VoteStack count={voteCount} {targetType} {targetId} />
|
||||||
<a {href} class="min-w-0 flex-1 group">
|
<a {href} class="group min-w-0 flex-1">
|
||||||
{#if unitCode}
|
{#if unitCode}
|
||||||
<p class="kicker">{unitCode}</p>
|
<p class="kicker">{unitCode}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<h3 class="mt-1 font-sans text-[15px] font-semibold leading-snug text-ink group-hover:text-primary">
|
<h3
|
||||||
|
class="text-ink group-hover:text-primary mt-1 font-sans text-[15px] leading-snug font-semibold"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="kicker mt-1.5">{meta}</p>
|
<p class="kicker mt-1.5">{meta}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isAuthenticated, initAuth, logout } from '$lib/stores/auth';
|
import { isAuthenticated, initAuth, logout } from "$lib/stores/auth";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from "$app/navigation";
|
||||||
import { page } from '$app/state';
|
import { page } from "$app/state";
|
||||||
|
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
let auth = $state(false);
|
let auth = $state(false);
|
||||||
|
|
@ -14,24 +14,26 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const path = $derived(page.url.pathname);
|
const path = $derived(page.url.pathname);
|
||||||
const onNotes = $derived(path === '/notes' || path.startsWith('/notes/'));
|
const onNotes = $derived(path === "/notes" || path.startsWith("/notes/"));
|
||||||
const onQuestions = $derived(path === '/questions' || path.startsWith('/questions/'));
|
const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/"));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="border-b border-rule bg-white">
|
<header class="border-rule border-b bg-white">
|
||||||
<div class="mx-auto flex h-16 max-w-4xl items-center justify-between px-4 sm:px-6">
|
<div class="mx-auto flex h-16 max-w-4xl items-center justify-between px-4 sm:px-6">
|
||||||
<a href="/" class="font-serif text-[1.65rem] leading-none text-ink">Notebook</a>
|
<a href="/" class="text-ink font-serif text-[1.65rem] leading-none">Notebook</a>
|
||||||
|
|
||||||
<nav class="hidden items-center gap-8 md:flex">
|
<nav class="hidden items-center gap-8 md:flex">
|
||||||
<a href="/notes" class="nav-link {onNotes ? 'nav-link-active' : ''}">Notes</a>
|
<a href="/notes" class="nav-link {onNotes ? 'nav-link-active' : ''}">Notes</a>
|
||||||
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}">Questions</a>
|
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}"
|
||||||
|
>Questions</a
|
||||||
|
>
|
||||||
{#if auth}
|
{#if auth}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
logout();
|
logout();
|
||||||
goto('/');
|
goto("/");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
|
|
@ -42,34 +44,48 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="p-1 text-muted hover:text-ink md:hidden"
|
class="text-muted hover:text-ink p-1 md:hidden"
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
<path stroke-linecap="square" d="M4 7h16M4 12h16M4 17h16" />
|
<path stroke-linecap="square" d="M4 7h16M4 12h16M4 17h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if mobileMenuOpen}
|
{#if mobileMenuOpen}
|
||||||
<nav class="space-y-3 border-t border-rule px-4 py-4 md:hidden">
|
<nav class="border-rule space-y-3 border-t px-4 py-4 md:hidden">
|
||||||
<a href="/notes" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Notes</a>
|
<a href="/notes" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
|
||||||
<a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Questions</a>
|
>Notes</a
|
||||||
|
>
|
||||||
|
<a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
|
||||||
|
>Questions</a
|
||||||
|
>
|
||||||
{#if auth}
|
{#if auth}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="nav-link block"
|
class="nav-link block"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
logout();
|
logout();
|
||||||
goto('/');
|
goto("/");
|
||||||
mobileMenuOpen = false;
|
mobileMenuOpen = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<a href="/auth/login" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Sign in</a>
|
<a
|
||||||
|
href="/auth/login"
|
||||||
|
class="nav-link block"
|
||||||
|
onclick={() => (mobileMenuOpen = false)}>Sign in</a
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { mutation } from '$lib/api';
|
import { mutation } from "$lib/api";
|
||||||
import { getToken } from '$lib/stores/auth';
|
import { getToken } from "$lib/stores/auth";
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
count = 0,
|
count = 0,
|
||||||
targetType,
|
targetType,
|
||||||
targetId
|
targetId,
|
||||||
}: {
|
}: {
|
||||||
count?: number;
|
count?: number;
|
||||||
targetType: 'note' | 'question';
|
targetType: "note" | "question";
|
||||||
targetId: string;
|
targetId: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|
@ -23,13 +23,13 @@
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
goto('/auth/login');
|
goto("/auth/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const result = await mutation('votes:cast', { token, targetType, targetId, value });
|
const result = await mutation("votes:cast", { token, targetType, targetId, value });
|
||||||
localCount = result.voteCount;
|
localCount = result.voteCount;
|
||||||
userVote = result.userVote;
|
userVote = result.userVote;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -43,30 +43,46 @@
|
||||||
<div class="flex w-8 shrink-0 flex-col gap-1">
|
<div class="flex w-8 shrink-0 flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-8 w-8 items-center justify-center border border-rule text-muted transition-colors hover:border-secondary hover:text-secondary {userVote === 1
|
class="border-rule text-muted hover:border-secondary hover:text-secondary flex h-8 w-8 items-center justify-center border transition-colors {userVote ===
|
||||||
|
1
|
||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
: ''}"
|
: ''}"
|
||||||
onclick={(e) => vote(1, e)}
|
onclick={(e) => vote(1, e)}
|
||||||
aria-label="Upvote"
|
aria-label="Upvote"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
<path d="M6 15l6-6 6 6" stroke-linecap="square" />
|
<path d="M6 15l6-6 6 6" stroke-linecap="square" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex h-8 w-8 items-center justify-center border border-rule font-sans text-xs text-ink">
|
<div
|
||||||
|
class="border-rule text-ink flex h-8 w-8 items-center justify-center border font-sans text-xs"
|
||||||
|
>
|
||||||
{voteCount}
|
{voteCount}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-8 w-8 items-center justify-center border border-rule text-muted transition-colors hover:border-secondary hover:text-secondary {userVote === -1
|
class="border-rule text-muted hover:border-secondary hover:text-secondary flex h-8 w-8 items-center justify-center border transition-colors {userVote ===
|
||||||
|
-1
|
||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
: ''}"
|
: ''}"
|
||||||
onclick={(e) => vote(-1, e)}
|
onclick={(e) => vote(-1, e)}
|
||||||
aria-label="Downvote"
|
aria-label="Downvote"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
<path d="M6 9l6 6 6-6" stroke-linecap="square" />
|
<path d="M6 9l6 6 6-6" stroke-linecap="square" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { getDb } from './db';
|
import { getDb } from "./db";
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
const DEAKIN_DOMAIN = 'deakin.edu.au';
|
const DEAKIN_DOMAIN = "deakin.edu.au";
|
||||||
|
|
||||||
type Db = ReturnType<typeof getDb>;
|
type Db = ReturnType<typeof getDb>;
|
||||||
|
|
||||||
|
|
@ -10,8 +10,8 @@ function newId(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateToken(): string {
|
function generateToken(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
let result = '';
|
let result = "";
|
||||||
for (let i = 0; i < 64; i++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
}
|
}
|
||||||
|
|
@ -20,9 +20,11 @@ function generateToken(): string {
|
||||||
|
|
||||||
function requireAuth(db: Db, token: string): Record<string, any> {
|
function requireAuth(db: Db, token: string): Record<string, any> {
|
||||||
const user = db
|
const user = db
|
||||||
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?')
|
.prepare(
|
||||||
|
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
|
||||||
|
)
|
||||||
.get(token) as Record<string, any> | undefined;
|
.get(token) as Record<string, any> | undefined;
|
||||||
if (!user) throw new Error('Not authenticated');
|
if (!user) throw new Error("Not authenticated");
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,37 +36,37 @@ function mapQuestion(row: Record<string, any>): Record<string, any> {
|
||||||
|
|
||||||
function usersRegister(db: Db, args: { email: string; name: string }) {
|
function usersRegister(db: Db, args: { email: string; name: string }) {
|
||||||
const email = args.email.toLowerCase();
|
const email = args.email.toLowerCase();
|
||||||
const emailDomain = email.split('@')[1]?.toLowerCase();
|
const emailDomain = email.split("@")[1]?.toLowerCase();
|
||||||
if (emailDomain !== DEAKIN_DOMAIN) {
|
if (emailDomain !== DEAKIN_DOMAIN) {
|
||||||
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
|
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = db
|
const existing = db
|
||||||
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?')
|
.prepare(
|
||||||
|
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?",
|
||||||
|
)
|
||||||
.get(email) as Record<string, any> | undefined;
|
.get(email) as Record<string, any> | undefined;
|
||||||
|
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare('UPDATE users SET sessionToken = ? WHERE id = ?').run(token, existing._id);
|
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, existing._id);
|
||||||
return { userId: existing._id, token, name: existing.name };
|
return { userId: existing._id, token, name: existing.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = newId();
|
const id = newId();
|
||||||
db.prepare('INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)').run(
|
db.prepare(
|
||||||
id,
|
"INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)",
|
||||||
email,
|
).run(id, email, args.name, token, Date.now());
|
||||||
args.name,
|
|
||||||
token,
|
|
||||||
Date.now()
|
|
||||||
);
|
|
||||||
return { userId: id, token, name: args.name };
|
return { userId: id, token, name: args.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
function usersGetByToken(db: Db, args: { token: string }) {
|
function usersGetByToken(db: Db, args: { token: string }) {
|
||||||
return (
|
return (
|
||||||
db
|
db
|
||||||
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?')
|
.prepare(
|
||||||
|
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
|
||||||
|
)
|
||||||
.get(args.token) ?? null
|
.get(args.token) ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -72,54 +74,62 @@ function usersGetByToken(db: Db, args: { token: string }) {
|
||||||
// ---- topics ----
|
// ---- topics ----
|
||||||
|
|
||||||
function topicsGetBySlug(db: Db, args: { slug: string }) {
|
function topicsGetBySlug(db: Db, args: { slug: string }) {
|
||||||
return db
|
return (
|
||||||
.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE slug = ?')
|
db
|
||||||
.get(args.slug) ?? null;
|
.prepare("SELECT id AS _id, name, slug, description FROM topics WHERE slug = ?")
|
||||||
|
.get(args.slug) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function topicsGetAll(db: Db) {
|
function topicsGetAll(db: Db) {
|
||||||
return db.prepare('SELECT id AS _id, name, slug, description FROM topics ORDER BY name ASC').all();
|
return db
|
||||||
|
.prepare("SELECT id AS _id, name, slug, description FROM topics ORDER BY name ASC")
|
||||||
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- units ----
|
// ---- units ----
|
||||||
|
|
||||||
function unitsGetByCode(db: Db, args: { code: string }) {
|
function unitsGetByCode(db: Db, args: { code: string }) {
|
||||||
return db
|
return (
|
||||||
.prepare('SELECT id AS _id, code, name, description FROM units WHERE code = ?')
|
db
|
||||||
.get(args.code.toUpperCase()) ?? null;
|
.prepare("SELECT id AS _id, code, name, description FROM units WHERE code = ?")
|
||||||
|
.get(args.code.toUpperCase()) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unitsGetAll(db: Db) {
|
function unitsGetAll(db: Db) {
|
||||||
return db.prepare('SELECT id AS _id, code, name, description FROM units ORDER BY code ASC').all();
|
return db
|
||||||
|
.prepare("SELECT id AS _id, code, name, description FROM units ORDER BY code ASC")
|
||||||
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
function unitsCreateCustom(db: Db, args: { code: string; name: string }) {
|
function unitsCreateCustom(db: Db, args: { code: string; name: string }) {
|
||||||
const code = args.code.toUpperCase();
|
const code = args.code.toUpperCase();
|
||||||
const existing = db.prepare('SELECT id AS _id FROM units WHERE code = ?').get(code) as
|
const existing = db.prepare("SELECT id AS _id FROM units WHERE code = ?").get(code) as
|
||||||
| { _id: string }
|
| { _id: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (existing) return existing._id;
|
if (existing) return existing._id;
|
||||||
|
|
||||||
const id = newId();
|
const id = newId();
|
||||||
db.prepare('INSERT INTO units (id, code, name) VALUES (?, ?, ?)').run(id, code, args.name);
|
db.prepare("INSERT INTO units (id, code, name) VALUES (?, ?, ?)").run(id, code, args.name);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- notes ----
|
// ---- notes ----
|
||||||
|
|
||||||
const NOTE_COLUMNS =
|
const NOTE_COLUMNS =
|
||||||
'id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount';
|
"id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount";
|
||||||
|
|
||||||
function notesCreate(
|
function notesCreate(
|
||||||
db: Db,
|
db: Db,
|
||||||
args: { token: string; title: string; content: string; topicId: string; unitId: string }
|
args: { token: string; title: string; content: string; topicId: string; unitId: string },
|
||||||
) {
|
) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const id = newId();
|
const id = newId();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount)
|
`INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)`,
|
||||||
).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now);
|
).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
@ -128,15 +138,21 @@ function notesList(db: Db, args: { topicId?: string; unitId?: string; limit?: nu
|
||||||
const limit = args.limit ?? 50;
|
const limit = args.limit ?? 50;
|
||||||
if (args.topicId) {
|
if (args.topicId) {
|
||||||
return db
|
return db
|
||||||
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`)
|
.prepare(
|
||||||
|
`SELECT ${NOTE_COLUMNS} FROM notes WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`,
|
||||||
|
)
|
||||||
.all(args.topicId, limit);
|
.all(args.topicId, limit);
|
||||||
}
|
}
|
||||||
if (args.unitId) {
|
if (args.unitId) {
|
||||||
return db
|
return db
|
||||||
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`)
|
.prepare(
|
||||||
|
`SELECT ${NOTE_COLUMNS} FROM notes WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`,
|
||||||
|
)
|
||||||
.all(args.unitId, limit);
|
.all(args.unitId, limit);
|
||||||
}
|
}
|
||||||
return db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes ORDER BY createdAt DESC LIMIT ?`).all(limit);
|
return db
|
||||||
|
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes ORDER BY createdAt DESC LIMIT ?`)
|
||||||
|
.all(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function notesSearch(db: Db, args: { query: string; limit?: number }) {
|
function notesSearch(db: Db, args: { query: string; limit?: number }) {
|
||||||
|
|
@ -145,7 +161,9 @@ function notesSearch(db: Db, args: { query: string; limit?: number }) {
|
||||||
.all(args.limit ?? 200) as Record<string, any>[];
|
.all(args.limit ?? 200) as Record<string, any>[];
|
||||||
const q = args.query.toLowerCase();
|
const q = args.query.toLowerCase();
|
||||||
return all.filter(
|
return all.filter(
|
||||||
(n) => String(n.title).toLowerCase().includes(q) || String(n.content).toLowerCase().includes(q)
|
(n) =>
|
||||||
|
String(n.title).toLowerCase().includes(q) ||
|
||||||
|
String(n.content).toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,28 +173,28 @@ function notesGetById(db: Db, args: { id: string }) {
|
||||||
|
|
||||||
function notesRemove(db: Db, args: { token: string; id: string }) {
|
function notesRemove(db: Db, args: { token: string; id: string }) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const note = db.prepare('SELECT id, authorId FROM notes WHERE id = ?').get(args.id) as
|
const note = db.prepare("SELECT id, authorId FROM notes WHERE id = ?").get(args.id) as
|
||||||
| { id: string; authorId: string }
|
| { id: string; authorId: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!note || note.authorId !== user._id) throw new Error('Not authorized');
|
if (!note || note.authorId !== user._id) throw new Error("Not authorized");
|
||||||
db.prepare('DELETE FROM notes WHERE id = ?').run(args.id);
|
db.prepare("DELETE FROM notes WHERE id = ?").run(args.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- questions ----
|
// ---- questions ----
|
||||||
|
|
||||||
const QUESTION_COLUMNS =
|
const QUESTION_COLUMNS =
|
||||||
'id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved';
|
"id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved";
|
||||||
|
|
||||||
function questionsCreate(
|
function questionsCreate(
|
||||||
db: Db,
|
db: Db,
|
||||||
args: { token: string; title: string; content: string; topicId: string; unitId: string }
|
args: { token: string; title: string; content: string; topicId: string; unitId: string },
|
||||||
) {
|
) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const id = newId();
|
const id = newId();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved)
|
`INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0)`,
|
||||||
).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now);
|
).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
@ -185,140 +203,170 @@ function questionsList(db: Db, args: { topicId?: string; unitId?: string; limit?
|
||||||
const limit = args.limit ?? 50;
|
const limit = args.limit ?? 50;
|
||||||
const rows = (
|
const rows = (
|
||||||
args.topicId
|
args.topicId
|
||||||
? db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`).all(args.topicId, limit)
|
? db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${QUESTION_COLUMNS} FROM questions WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(args.topicId, limit)
|
||||||
: args.unitId
|
: args.unitId
|
||||||
? db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`).all(args.unitId, limit)
|
? db
|
||||||
: db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions ORDER BY createdAt DESC LIMIT ?`).all(limit)
|
.prepare(
|
||||||
|
`SELECT ${QUESTION_COLUMNS} FROM questions WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(args.unitId, limit)
|
||||||
|
: db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${QUESTION_COLUMNS} FROM questions ORDER BY createdAt DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(limit)
|
||||||
) as Record<string, any>[];
|
) as Record<string, any>[];
|
||||||
return rows.map(mapQuestion);
|
return rows.map(mapQuestion);
|
||||||
}
|
}
|
||||||
|
|
||||||
function questionsGetById(db: Db, args: { id: string }) {
|
function questionsGetById(db: Db, args: { id: string }) {
|
||||||
const row = db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`).get(args.id) as
|
const row = db
|
||||||
| Record<string, any>
|
.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`)
|
||||||
| undefined;
|
.get(args.id) as Record<string, any> | undefined;
|
||||||
return row ? mapQuestion(row) : null;
|
return row ? mapQuestion(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function questionsMarkSolved(db: Db, args: { token: string; id: string }) {
|
function questionsMarkSolved(db: Db, args: { token: string; id: string }) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const question = db.prepare('SELECT id, authorId FROM questions WHERE id = ?').get(args.id) as
|
const question = db.prepare("SELECT id, authorId FROM questions WHERE id = ?").get(args.id) as
|
||||||
| { id: string; authorId: string }
|
| { id: string; authorId: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!question || question.authorId !== user._id) throw new Error('Not authorized');
|
if (!question || question.authorId !== user._id) throw new Error("Not authorized");
|
||||||
db.prepare('UPDATE questions SET solved = 1 WHERE id = ?').run(args.id);
|
db.prepare("UPDATE questions SET solved = 1 WHERE id = ?").run(args.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function questionsRemove(db: Db, args: { token: string; id: string }) {
|
function questionsRemove(db: Db, args: { token: string; id: string }) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const question = db.prepare('SELECT id, authorId FROM questions WHERE id = ?').get(args.id) as
|
const question = db.prepare("SELECT id, authorId FROM questions WHERE id = ?").get(args.id) as
|
||||||
| { id: string; authorId: string }
|
| { id: string; authorId: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!question || question.authorId !== user._id) throw new Error('Not authorized');
|
if (!question || question.authorId !== user._id) throw new Error("Not authorized");
|
||||||
db.prepare('DELETE FROM questions WHERE id = ?').run(args.id);
|
db.prepare("DELETE FROM questions WHERE id = ?").run(args.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- comments ----
|
// ---- comments ----
|
||||||
|
|
||||||
const COMMENT_COLUMNS =
|
const COMMENT_COLUMNS =
|
||||||
'id AS _id, content, authorId, authorName, parentId, questionId, parentCommentId, createdAt';
|
"id AS _id, content, authorId, authorName, parentId, questionId, parentCommentId, createdAt";
|
||||||
|
|
||||||
function commentsCreateOnNote(
|
function commentsCreateOnNote(
|
||||||
db: Db,
|
db: Db,
|
||||||
args: { token: string; content: string; parentId: string; parentCommentId?: string }
|
args: { token: string; content: string; parentId: string; parentCommentId?: string },
|
||||||
) {
|
) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const id = newId();
|
const id = newId();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt)
|
`INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(id, args.content, user._id, user.name, args.parentId, args.parentCommentId ?? null, Date.now());
|
).run(
|
||||||
db.prepare('UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?').run(args.parentId);
|
id,
|
||||||
|
args.content,
|
||||||
|
user._id,
|
||||||
|
user.name,
|
||||||
|
args.parentId,
|
||||||
|
args.parentCommentId ?? null,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
db.prepare("UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?").run(args.parentId);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentsCreateOnQuestion(
|
function commentsCreateOnQuestion(
|
||||||
db: Db,
|
db: Db,
|
||||||
args: { token: string; content: string; questionId: string; parentCommentId?: string }
|
args: { token: string; content: string; questionId: string; parentCommentId?: string },
|
||||||
) {
|
) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const id = newId();
|
const id = newId();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt)
|
`INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(id, args.content, user._id, user.name, args.questionId, args.parentCommentId ?? null, Date.now());
|
).run(
|
||||||
db.prepare('UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?').run(args.questionId);
|
id,
|
||||||
|
args.content,
|
||||||
|
user._id,
|
||||||
|
user.name,
|
||||||
|
args.questionId,
|
||||||
|
args.parentCommentId ?? null,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
db.prepare("UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?").run(
|
||||||
|
args.questionId,
|
||||||
|
);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentsListByNote(db: Db, args: { noteId: string }) {
|
function commentsListByNote(db: Db, args: { noteId: string }) {
|
||||||
return db
|
return db
|
||||||
.prepare(`SELECT ${COMMENT_COLUMNS} FROM comments WHERE parentId = ? ORDER BY createdAt ASC`)
|
.prepare(
|
||||||
|
`SELECT ${COMMENT_COLUMNS} FROM comments WHERE parentId = ? ORDER BY createdAt ASC`,
|
||||||
|
)
|
||||||
.all(args.noteId);
|
.all(args.noteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentsListByQuestion(db: Db, args: { questionId: string }) {
|
function commentsListByQuestion(db: Db, args: { questionId: string }) {
|
||||||
return db
|
return db
|
||||||
.prepare(`SELECT ${COMMENT_COLUMNS} FROM comments WHERE questionId = ? ORDER BY createdAt ASC`)
|
.prepare(
|
||||||
|
`SELECT ${COMMENT_COLUMNS} FROM comments WHERE questionId = ? ORDER BY createdAt ASC`,
|
||||||
|
)
|
||||||
.all(args.questionId);
|
.all(args.questionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentsRemove(db: Db, args: { token: string; id: string }) {
|
function commentsRemove(db: Db, args: { token: string; id: string }) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const comment = db.prepare('SELECT id, authorId FROM comments WHERE id = ?').get(args.id) as
|
const comment = db.prepare("SELECT id, authorId FROM comments WHERE id = ?").get(args.id) as
|
||||||
| { id: string; authorId: string }
|
| { id: string; authorId: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!comment || comment.authorId !== user._id) throw new Error('Not authorized');
|
if (!comment || comment.authorId !== user._id) throw new Error("Not authorized");
|
||||||
db.prepare('DELETE FROM comments WHERE id = ?').run(args.id);
|
db.prepare("DELETE FROM comments WHERE id = ?").run(args.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- votes ----
|
// ---- votes ----
|
||||||
|
|
||||||
function votesCast(
|
function votesCast(
|
||||||
db: Db,
|
db: Db,
|
||||||
args: { token: string; targetType: 'note' | 'question'; targetId: string; value: 1 | -1 }
|
args: { token: string; targetType: "note" | "question"; targetId: string; value: 1 | -1 },
|
||||||
) {
|
) {
|
||||||
const user = requireAuth(db, args.token);
|
const user = requireAuth(db, args.token);
|
||||||
const isNote = args.targetType === 'note';
|
const isNote = args.targetType === "note";
|
||||||
const table = isNote ? 'notes' : 'questions';
|
const table = isNote ? "notes" : "questions";
|
||||||
const doc = db.prepare(`SELECT id, voteCount FROM ${table} WHERE id = ?`).get(args.targetId) as
|
const doc = db.prepare(`SELECT id, voteCount FROM ${table} WHERE id = ?`).get(args.targetId) as
|
||||||
| { id: string; voteCount: number }
|
| { id: string; voteCount: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!doc) throw new Error('Not found');
|
if (!doc) throw new Error("Not found");
|
||||||
|
|
||||||
const existing = db
|
const existing = db
|
||||||
.prepare('SELECT id, value FROM votes WHERE userId = ? AND targetType = ? AND targetId = ?')
|
.prepare("SELECT id, value FROM votes WHERE userId = ? AND targetType = ? AND targetId = ?")
|
||||||
.get(user._id, args.targetType, args.targetId) as { id: string; value: number } | undefined;
|
.get(user._id, args.targetType, args.targetId) as { id: string; value: number } | undefined;
|
||||||
|
|
||||||
let voteCount = doc.voteCount;
|
let voteCount = doc.voteCount;
|
||||||
let userVote: number = args.value;
|
let userVote: number = args.value;
|
||||||
|
|
||||||
db.exec('BEGIN');
|
db.exec("BEGIN");
|
||||||
try {
|
try {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.value === args.value) {
|
if (existing.value === args.value) {
|
||||||
db.prepare('DELETE FROM votes WHERE id = ?').run(existing.id);
|
db.prepare("DELETE FROM votes WHERE id = ?").run(existing.id);
|
||||||
voteCount -= args.value;
|
voteCount -= args.value;
|
||||||
userVote = 0;
|
userVote = 0;
|
||||||
} else {
|
} else {
|
||||||
db.prepare('UPDATE votes SET value = ? WHERE id = ?').run(args.value, existing.id);
|
db.prepare("UPDATE votes SET value = ? WHERE id = ?").run(args.value, existing.id);
|
||||||
voteCount = voteCount - existing.value + args.value;
|
voteCount = voteCount - existing.value + args.value;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
db.prepare('INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, ?, ?, ?)').run(
|
db.prepare(
|
||||||
newId(),
|
"INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, ?, ?, ?)",
|
||||||
user._id,
|
).run(newId(), user._id, args.targetType, args.targetId, args.value);
|
||||||
args.targetType,
|
|
||||||
args.targetId,
|
|
||||||
args.value
|
|
||||||
);
|
|
||||||
voteCount += args.value;
|
voteCount += args.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`UPDATE ${table} SET voteCount = ? WHERE id = ?`).run(voteCount, args.targetId);
|
db.prepare(`UPDATE ${table} SET voteCount = ? WHERE id = ?`).run(voteCount, args.targetId);
|
||||||
db.exec('COMMIT');
|
db.exec("COMMIT");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
db.exec('ROLLBACK');
|
db.exec("ROLLBACK");
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,21 +381,29 @@ function getNoteWithDetails(db: Db, args: { id: string }) {
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!note) return null;
|
if (!note) return null;
|
||||||
|
|
||||||
const topic = db.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE id = ?').get(note.topicId);
|
const topic = db
|
||||||
const unit = db.prepare('SELECT id AS _id, code, name, description FROM units WHERE id = ?').get(note.unitId);
|
.prepare("SELECT id AS _id, name, slug, description FROM topics WHERE id = ?")
|
||||||
|
.get(note.topicId);
|
||||||
|
const unit = db
|
||||||
|
.prepare("SELECT id AS _id, code, name, description FROM units WHERE id = ?")
|
||||||
|
.get(note.unitId);
|
||||||
const comments = commentsListByNote(db, { noteId: note._id });
|
const comments = commentsListByNote(db, { noteId: note._id });
|
||||||
|
|
||||||
return { ...note, topic, unit, comments };
|
return { ...note, topic, unit, comments };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQuestionWithDetails(db: Db, args: { id: string }) {
|
function getQuestionWithDetails(db: Db, args: { id: string }) {
|
||||||
const question = db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`).get(args.id) as
|
const question = db
|
||||||
| Record<string, any>
|
.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`)
|
||||||
| undefined;
|
.get(args.id) as Record<string, any> | undefined;
|
||||||
if (!question) return null;
|
if (!question) return null;
|
||||||
|
|
||||||
const topic = db.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE id = ?').get(question.topicId);
|
const topic = db
|
||||||
const unit = db.prepare('SELECT id AS _id, code, name, description FROM units WHERE id = ?').get(question.unitId);
|
.prepare("SELECT id AS _id, name, slug, description FROM topics WHERE id = ?")
|
||||||
|
.get(question.topicId);
|
||||||
|
const unit = db
|
||||||
|
.prepare("SELECT id AS _id, code, name, description FROM units WHERE id = ?")
|
||||||
|
.get(question.unitId);
|
||||||
const answers = commentsListByQuestion(db, { questionId: question._id });
|
const answers = commentsListByQuestion(db, { questionId: question._id });
|
||||||
|
|
||||||
return { ...mapQuestion(question), topic, unit, answers };
|
return { ...mapQuestion(question), topic, unit, answers };
|
||||||
|
|
@ -358,31 +414,31 @@ function getQuestionWithDetails(db: Db, args: { id: string }) {
|
||||||
type Handler = (db: Db, args: any) => any;
|
type Handler = (db: Db, args: any) => any;
|
||||||
|
|
||||||
const handlers: Record<string, Handler> = {
|
const handlers: Record<string, Handler> = {
|
||||||
'users:register': usersRegister,
|
"users:register": usersRegister,
|
||||||
'users:getByToken': usersGetByToken,
|
"users:getByToken": usersGetByToken,
|
||||||
'topics:getBySlug': topicsGetBySlug,
|
"topics:getBySlug": topicsGetBySlug,
|
||||||
'topics:getAll': topicsGetAll,
|
"topics:getAll": topicsGetAll,
|
||||||
'units:getByCode': unitsGetByCode,
|
"units:getByCode": unitsGetByCode,
|
||||||
'units:getAll': unitsGetAll,
|
"units:getAll": unitsGetAll,
|
||||||
'units:createCustom': unitsCreateCustom,
|
"units:createCustom": unitsCreateCustom,
|
||||||
'notes:create': notesCreate,
|
"notes:create": notesCreate,
|
||||||
'notes:list': notesList,
|
"notes:list": notesList,
|
||||||
'notes:search': notesSearch,
|
"notes:search": notesSearch,
|
||||||
'notes:getById': notesGetById,
|
"notes:getById": notesGetById,
|
||||||
'notes:remove': notesRemove,
|
"notes:remove": notesRemove,
|
||||||
'questions:create': questionsCreate,
|
"questions:create": questionsCreate,
|
||||||
'questions:list': questionsList,
|
"questions:list": questionsList,
|
||||||
'questions:getById': questionsGetById,
|
"questions:getById": questionsGetById,
|
||||||
'questions:markSolved': questionsMarkSolved,
|
"questions:markSolved": questionsMarkSolved,
|
||||||
'questions:remove': questionsRemove,
|
"questions:remove": questionsRemove,
|
||||||
'comments:createOnNote': commentsCreateOnNote,
|
"comments:createOnNote": commentsCreateOnNote,
|
||||||
'comments:createOnQuestion': commentsCreateOnQuestion,
|
"comments:createOnQuestion": commentsCreateOnQuestion,
|
||||||
'comments:listByNote': commentsListByNote,
|
"comments:listByNote": commentsListByNote,
|
||||||
'comments:listByQuestion': commentsListByQuestion,
|
"comments:listByQuestion": commentsListByQuestion,
|
||||||
'comments:remove': commentsRemove,
|
"comments:remove": commentsRemove,
|
||||||
'votes:cast': votesCast,
|
"votes:cast": votesCast,
|
||||||
'details:getNoteWithDetails': getNoteWithDetails,
|
"details:getNoteWithDetails": getNoteWithDetails,
|
||||||
'details:getQuestionWithDetails': getQuestionWithDetails,
|
"details:getQuestionWithDetails": getQuestionWithDetails,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function call(fn: string, args: Record<string, any> = {}): any {
|
export function call(fn: string, args: Record<string, any> = {}): any {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from "node:fs";
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from "node:path";
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
const DB_PATH = resolve(process.env.DATABASE_PATH ?? 'data/dsec.db');
|
const DB_PATH = resolve(process.env.DATABASE_PATH ?? "data/dsec.db");
|
||||||
|
|
||||||
let db: DatabaseSync | null = null;
|
let db: DatabaseSync | null = null;
|
||||||
|
|
||||||
|
|
@ -92,62 +92,118 @@ function createSchema(database: DatabaseSync) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEED_TOPICS = [
|
const SEED_TOPICS = [
|
||||||
{ name: 'Algorithms', slug: 'algorithms', description: 'Algorithm design, analysis, and common patterns' },
|
{
|
||||||
{ name: 'Data Structures', slug: 'data-structures', description: 'Arrays, linked lists, trees, graphs, and more' },
|
name: "Algorithms",
|
||||||
{ name: 'Networking', slug: 'networking', description: 'Computer networks, protocols, and architectures' },
|
slug: "algorithms",
|
||||||
{ name: 'Cybersecurity', slug: 'cybersecurity', description: 'Security principles, threats, and defenses' },
|
description: "Algorithm design, analysis, and common patterns",
|
||||||
{ name: 'Databases', slug: 'databases', description: 'Relational and NoSQL databases, SQL, and design' },
|
},
|
||||||
{ name: 'Web Development', slug: 'web-development', description: 'HTML, CSS, JavaScript, and frameworks' },
|
{
|
||||||
{ name: 'Operating Systems', slug: 'operating-systems', description: 'OS concepts, processes, memory, and file systems' },
|
name: "Data Structures",
|
||||||
{ name: 'Software Engineering', slug: 'software-engineering', description: 'Design patterns, testing, and methodologies' },
|
slug: "data-structures",
|
||||||
{ name: 'Programming Languages', slug: 'programming-languages', description: 'Language concepts, paradigms, and syntax' },
|
description: "Arrays, linked lists, trees, graphs, and more",
|
||||||
{ name: 'Mathematics', slug: 'mathematics', description: 'Discrete math, linear algebra, statistics for CS' },
|
},
|
||||||
{ name: 'Machine Learning', slug: 'machine-learning', description: 'ML concepts, models, and techniques' },
|
{
|
||||||
{ name: 'Cloud Computing', slug: 'cloud-computing', description: 'Cloud platforms, services, and architecture' },
|
name: "Networking",
|
||||||
{ name: 'Mobile Development', slug: 'mobile-development', description: 'iOS, Android, and cross-platform development' },
|
slug: "networking",
|
||||||
{ name: 'DevOps', slug: 'devops', description: 'CI/CD, containers, and infrastructure' },
|
description: "Computer networks, protocols, and architectures",
|
||||||
{ name: 'Computer Architecture', slug: 'computer-architecture', description: 'CPU, memory, and hardware design' },
|
},
|
||||||
|
{
|
||||||
|
name: "Cybersecurity",
|
||||||
|
slug: "cybersecurity",
|
||||||
|
description: "Security principles, threats, and defenses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Databases",
|
||||||
|
slug: "databases",
|
||||||
|
description: "Relational and NoSQL databases, SQL, and design",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Web Development",
|
||||||
|
slug: "web-development",
|
||||||
|
description: "HTML, CSS, JavaScript, and frameworks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Operating Systems",
|
||||||
|
slug: "operating-systems",
|
||||||
|
description: "OS concepts, processes, memory, and file systems",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Software Engineering",
|
||||||
|
slug: "software-engineering",
|
||||||
|
description: "Design patterns, testing, and methodologies",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Programming Languages",
|
||||||
|
slug: "programming-languages",
|
||||||
|
description: "Language concepts, paradigms, and syntax",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mathematics",
|
||||||
|
slug: "mathematics",
|
||||||
|
description: "Discrete math, linear algebra, statistics for CS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Machine Learning",
|
||||||
|
slug: "machine-learning",
|
||||||
|
description: "ML concepts, models, and techniques",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cloud Computing",
|
||||||
|
slug: "cloud-computing",
|
||||||
|
description: "Cloud platforms, services, and architecture",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mobile Development",
|
||||||
|
slug: "mobile-development",
|
||||||
|
description: "iOS, Android, and cross-platform development",
|
||||||
|
},
|
||||||
|
{ name: "DevOps", slug: "devops", description: "CI/CD, containers, and infrastructure" },
|
||||||
|
{
|
||||||
|
name: "Computer Architecture",
|
||||||
|
slug: "computer-architecture",
|
||||||
|
description: "CPU, memory, and hardware design",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SEED_UNITS = [
|
const SEED_UNITS = [
|
||||||
{ code: 'SIT102', name: 'Introduction to Programming' },
|
{ code: "SIT102", name: "Introduction to Programming" },
|
||||||
{ code: 'SIT111', name: 'Computer Systems' },
|
{ code: "SIT111", name: "Computer Systems" },
|
||||||
{ code: 'SIT192', name: 'Discrete Mathematics' },
|
{ code: "SIT192", name: "Discrete Mathematics" },
|
||||||
{ code: 'SIT202', name: 'Computer Networks and Communication' },
|
{ code: "SIT202", name: "Computer Networks and Communication" },
|
||||||
{ code: 'SIT210', name: 'Embedded Systems Development' },
|
{ code: "SIT210", name: "Embedded Systems Development" },
|
||||||
{ code: 'SIT221', name: 'Data Structures and Algorithms' },
|
{ code: "SIT221", name: "Data Structures and Algorithms" },
|
||||||
{ code: 'SIT232', name: 'Object-Oriented Development' },
|
{ code: "SIT232", name: "Object-Oriented Development" },
|
||||||
{ code: 'SIT281', name: 'Cryptography' },
|
{ code: "SIT281", name: "Cryptography" },
|
||||||
{ code: 'SIT282', name: 'Computer Forensics' },
|
{ code: "SIT282", name: "Computer Forensics" },
|
||||||
{ code: 'SIT283', name: 'Ethical Hacking' },
|
{ code: "SIT283", name: "Ethical Hacking" },
|
||||||
{ code: 'SIT284', name: 'Cyber Security Management' },
|
{ code: "SIT284", name: "Cyber Security Management" },
|
||||||
{ code: 'SIT313', name: 'Full Stack Web Development' },
|
{ code: "SIT313", name: "Full Stack Web Development" },
|
||||||
{ code: 'SIT315', name: 'Programming Paradigms' },
|
{ code: "SIT315", name: "Programming Paradigms" },
|
||||||
{ code: 'SIT323', name: 'Cloud Native Application Development' },
|
{ code: "SIT323", name: "Cloud Native Application Development" },
|
||||||
{ code: 'SIT331', name: 'IT Security' },
|
{ code: "SIT331", name: "IT Security" },
|
||||||
{ code: 'SIT374', name: 'Team Project (A) - Project Management' },
|
{ code: "SIT374", name: "Team Project (A) - Project Management" },
|
||||||
{ code: 'SIT378', name: 'Team Project (B) - Execution' },
|
{ code: "SIT378", name: "Team Project (B) - Execution" },
|
||||||
{ code: 'SIT379', name: 'Ethical Hacking' },
|
{ code: "SIT379", name: "Ethical Hacking" },
|
||||||
{ code: 'SIT384', name: 'Cyber Security Analytics' },
|
{ code: "SIT384", name: "Cyber Security Analytics" },
|
||||||
{ code: 'SIT393', name: 'Computing Internship' },
|
{ code: "SIT393", name: "Computing Internship" },
|
||||||
{ code: 'MIS771', name: 'Business Intelligence and Data Warehousing' },
|
{ code: "MIS771", name: "Business Intelligence and Data Warehousing" },
|
||||||
{ code: 'MIS772', name: 'Predictive Analytics' },
|
{ code: "MIS772", name: "Predictive Analytics" },
|
||||||
{ code: 'MIS782', name: 'Information Security Governance' },
|
{ code: "MIS782", name: "Information Security Governance" },
|
||||||
{ code: 'MIS784', name: 'Cyber Security Management and Practices' },
|
{ code: "MIS784", name: "Cyber Security Management and Practices" },
|
||||||
{ code: 'MIS785', name: 'IT Strategy and Governance' },
|
{ code: "MIS785", name: "IT Strategy and Governance" },
|
||||||
{ code: 'MIS798', name: 'Business Process Management' },
|
{ code: "MIS798", name: "Business Process Management" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function seed(database: DatabaseSync) {
|
function seed(database: DatabaseSync) {
|
||||||
const existing = database.prepare('SELECT COUNT(*) AS c FROM topics').get() as { c: number };
|
const existing = database.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number };
|
||||||
if (existing.c > 0) return;
|
if (existing.c > 0) return;
|
||||||
|
|
||||||
const insertTopic = database.prepare(
|
const insertTopic = database.prepare(
|
||||||
'INSERT INTO topics (id, name, slug, description) VALUES (?, ?, ?, ?)'
|
"INSERT INTO topics (id, name, slug, description) VALUES (?, ?, ?, ?)",
|
||||||
);
|
);
|
||||||
const insertUnit = database.prepare('INSERT INTO units (id, code, name) VALUES (?, ?, ?)');
|
const insertUnit = database.prepare("INSERT INTO units (id, code, name) VALUES (?, ?, ?)");
|
||||||
|
|
||||||
database.exec('BEGIN');
|
database.exec("BEGIN");
|
||||||
try {
|
try {
|
||||||
for (const topic of SEED_TOPICS) {
|
for (const topic of SEED_TOPICS) {
|
||||||
insertTopic.run(randomUUID(), topic.name, topic.slug, topic.description ?? null);
|
insertTopic.run(randomUUID(), topic.name, topic.slug, topic.description ?? null);
|
||||||
|
|
@ -155,9 +211,9 @@ function seed(database: DatabaseSync) {
|
||||||
for (const unit of SEED_UNITS) {
|
for (const unit of SEED_UNITS) {
|
||||||
insertUnit.run(randomUUID(), unit.code, unit.name);
|
insertUnit.run(randomUUID(), unit.code, unit.name);
|
||||||
}
|
}
|
||||||
database.exec('COMMIT');
|
database.exec("COMMIT");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
database.exec('ROLLBACK');
|
database.exec("ROLLBACK");
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
describe('auth store', () => {
|
describe("auth store", () => {
|
||||||
it('should be importable', () => {
|
it("should be importable", () => {
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from "svelte/store";
|
||||||
import { query, mutation } from '$lib/api';
|
import { query, mutation } from "$lib/api";
|
||||||
import type { UserDoc } from '$lib/types';
|
import type { UserDoc } from "$lib/types";
|
||||||
|
|
||||||
export const currentUser = writable<UserDoc | null>(null);
|
export const currentUser = writable<UserDoc | null>(null);
|
||||||
export const isAuthenticated = writable(false);
|
export const isAuthenticated = writable(false);
|
||||||
|
|
||||||
const STORAGE_KEY = 'dsec_session';
|
const STORAGE_KEY = "dsec_session";
|
||||||
|
|
||||||
export async function initAuth() {
|
export async function initAuth() {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
const { token } = JSON.parse(stored);
|
const { token } = JSON.parse(stored);
|
||||||
const user = await query('users:getByToken', { token });
|
const user = await query("users:getByToken", { token });
|
||||||
if (user) {
|
if (user) {
|
||||||
currentUser.set(user as UserDoc);
|
currentUser.set(user as UserDoc);
|
||||||
isAuthenticated.set(true);
|
isAuthenticated.set(true);
|
||||||
|
|
@ -26,9 +26,15 @@ export async function initAuth() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(email: string, name: string) {
|
export async function login(email: string, name: string) {
|
||||||
const result = await mutation('users:register', { email, name });
|
const result = await mutation("users:register", { email, name });
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token }));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token }));
|
||||||
currentUser.set({ _id: result.userId, email: email.toLowerCase(), name, sessionToken: result.token, _creationTime: Date.now() } as UserDoc);
|
currentUser.set({
|
||||||
|
_id: result.userId,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
name,
|
||||||
|
sessionToken: result.token,
|
||||||
|
_creationTime: Date.now(),
|
||||||
|
} as UserDoc);
|
||||||
isAuthenticated.set(true);
|
isAuthenticated.set(true);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export function timeAgo(ts: number): string {
|
export function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts;
|
const diff = Date.now() - ts;
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
if (mins < 1) return 'JUST NOW';
|
if (mins < 1) return "JUST NOW";
|
||||||
if (mins < 60) return `${mins}M AGO`;
|
if (mins < 60) return `${mins}M AGO`;
|
||||||
const hours = Math.floor(mins / 60);
|
const hours = Math.floor(mins / 60);
|
||||||
if (hours < 24) return `${hours}H AGO`;
|
if (hours < 24) return `${hours}H AGO`;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Navbar from '$lib/components/Navbar.svelte';
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
import { initAuth } from '$lib/stores/auth';
|
import { initAuth } from "$lib/stores/auth";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import './layout.css';
|
import "./layout.css";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|
@ -22,12 +22,15 @@
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
<footer class="mt-16 border-t border-rule">
|
<footer class="border-rule mt-16 border-t">
|
||||||
<div class="mx-auto flex max-w-4xl flex-col gap-2 px-4 py-8 sm:flex-row sm:items-end sm:justify-between sm:px-6">
|
<div
|
||||||
|
class="mx-auto flex max-w-4xl flex-col gap-2 px-4 py-8 sm:flex-row sm:items-end sm:justify-between sm:px-6"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<p class="kicker">Notebook — written by students, for students.</p>
|
<p class="kicker">Notebook — written by students, for students.</p>
|
||||||
<p class="mt-2 text-xs text-faint">
|
<p class="text-faint mt-2 text-xs">
|
||||||
DSEC Notebook is a community resource for Deakin University students. Not affiliated with Deakin University.
|
DSEC Notebook is a community resource for Deakin University students. Not
|
||||||
|
affiliated with Deakin University.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/search" class="kicker hover:text-primary">Search</a>
|
<a href="/search" class="kicker hover:text-primary">Search</a>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query } from '$lib/api';
|
import { query } from "$lib/api";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import type { UnitDoc } from '$lib/types';
|
import type { UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
let units: UnitDoc[] = $state([]);
|
let units: UnitDoc[] = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
units = (await query('units:getAll')) as UnitDoc[];
|
units = (await query("units:getAll")) as UnitDoc[];
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -17,13 +17,16 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<section class="pb-16 pt-6">
|
<section class="pt-6 pb-16">
|
||||||
<p class="kicker">Written by students</p>
|
<p class="kicker">Written by students</p>
|
||||||
<h1 class="mt-4 max-w-xl font-serif text-4xl font-medium leading-[1.15] text-ink sm:text-5xl">
|
<h1
|
||||||
|
class="text-ink mt-4 max-w-xl font-serif text-4xl leading-[1.15] font-medium sm:text-5xl"
|
||||||
|
>
|
||||||
One notebook for every unit, every topic.
|
One notebook for every unit, every topic.
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-5 max-w-lg text-[15px] leading-relaxed text-muted">
|
<p class="text-muted mt-5 max-w-lg text-[15px] leading-relaxed">
|
||||||
Post notes in markdown, ask questions, and browse by unit. A shared notebook for Deakin students studying IT, computer science, and cybersecurity.
|
Post notes in markdown, ask questions, and browse by unit. A shared notebook for Deakin
|
||||||
|
students studying IT, computer science, and cybersecurity.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-8 flex flex-wrap gap-3">
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
<a href="/notes" class="btn-primary">Browse notes</a>
|
<a href="/notes" class="btn-primary">Browse notes</a>
|
||||||
|
|
@ -31,7 +34,7 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-t border-rule pt-10">
|
<section class="border-rule border-t pt-10">
|
||||||
<p class="kicker mb-6">Units</p>
|
<p class="kicker mb-6">Units</p>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="kicker">Loading</p>
|
<p class="kicker">Loading</p>
|
||||||
|
|
@ -40,10 +43,12 @@
|
||||||
{#each units as unit}
|
{#each units as unit}
|
||||||
<a
|
<a
|
||||||
href="/units/{unit.code}"
|
href="/units/{unit.code}"
|
||||||
class="group flex items-baseline justify-between gap-4 border-b border-rule py-3"
|
class="group border-rule flex items-baseline justify-between gap-4 border-b px-5 py-3"
|
||||||
>
|
>
|
||||||
<span class="font-serif text-lg text-ink group-hover:text-primary">{unit.code}</span>
|
<span class="text-ink group-hover:text-primary font-serif text-lg"
|
||||||
<span class="truncate text-sm text-muted">{unit.name}</span>
|
>{unit.code}</span
|
||||||
|
>
|
||||||
|
<span class="text-muted truncate text-sm">{unit.name}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from "@sveltejs/kit";
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from "./$types";
|
||||||
import { call } from '$lib/server/api';
|
import { call } from "$lib/server/api";
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
let fn: string;
|
let fn: string;
|
||||||
|
|
@ -8,18 +8,18 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
fn = String(body?.fn ?? '');
|
fn = String(body?.fn ?? "");
|
||||||
args = (body?.args ?? {}) as Record<string, unknown>;
|
args = (body?.args ?? {}) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
return json({ ok: false, error: 'Invalid request body' }, { status: 400 });
|
return json({ ok: false, error: "Invalid request body" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = call(fn, args as Record<string, any>);
|
const result = call(fn, args as Record<string, any>);
|
||||||
return json({ ok: true, result });
|
return json({ ok: true, result });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const message = err?.message ?? 'Internal error';
|
const message = err?.message ?? "Internal error";
|
||||||
const status = message === 'Not authenticated' || message === 'Not authorized' ? 401 : 400;
|
const status = message === "Not authenticated" || message === "Not authorized" ? 401 : 400;
|
||||||
return json({ ok: false, error: message }, { status });
|
return json({ ok: false, error: message }, { status });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { login } from '$lib/stores/auth';
|
import { login } from "$lib/stores/auth";
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
let email = $state('');
|
let email = $state("");
|
||||||
let name = $state('');
|
let name = $state("");
|
||||||
let error = $state('');
|
let error = $state("");
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = "";
|
||||||
|
|
||||||
if (!email.trim() || !name.trim()) {
|
if (!email.trim() || !name.trim()) {
|
||||||
error = 'Please fill in all fields';
|
error = "Please fill in all fields";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!email.toLowerCase().endsWith('@deakin.edu.au')) {
|
if (!email.toLowerCase().endsWith("@deakin.edu.au")) {
|
||||||
error = 'Only @deakin.edu.au email addresses are allowed';
|
error = "Only @deakin.edu.au email addresses are allowed";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
await login(email.trim(), name.trim());
|
await login(email.trim(), name.trim());
|
||||||
goto('/');
|
goto("/");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err.message ?? 'Login failed. Please try again.';
|
error = err.message ?? "Login failed. Please try again.";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -40,13 +40,20 @@
|
||||||
<div class="page flex min-h-[calc(100vh-220px)] items-center">
|
<div class="page flex min-h-[calc(100vh-220px)] items-center">
|
||||||
<div class="mx-auto w-full max-w-md">
|
<div class="mx-auto w-full max-w-md">
|
||||||
<p class="kicker">Account</p>
|
<p class="kicker">Account</p>
|
||||||
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">Sign in</h1>
|
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">Sign in</h1>
|
||||||
<p class="mt-2 mb-8 text-[15px] text-muted">Use your Deakin email to contribute.</p>
|
<p class="text-muted mt-2 mb-8 text-[15px]">Use your Deakin email to contribute.</p>
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-5 border-t border-rule pt-8">
|
<form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
|
||||||
<div>
|
<div>
|
||||||
<label for="name" class="kicker mb-2 block">Full name</label>
|
<label for="name" class="kicker mb-2 block">Full name</label>
|
||||||
<input id="name" type="text" bind:value={name} placeholder="Jane Smith" class="field" required />
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
class="field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -55,22 +62,24 @@
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
placeholder="j.smith@deakin.edu.au"
|
placeholder="@deakin.edu.au"
|
||||||
class="field"
|
class="field"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-xs text-faint">Must be an @deakin.edu.au address</p>
|
<p class="text-faint mt-2 text-xs">Must be an @deakin.edu.au address</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-sm text-primary">{error}</p>
|
<p class="text-primary text-sm">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} class="btn-primary w-full">
|
<button type="submit" disabled={loading} class="btn-primary w-full">
|
||||||
{loading ? 'Signing in...' : 'Sign in / Register'}
|
{loading ? "Signing in..." : "Sign in / Register"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p class="text-xs text-faint">By signing in, you agree that your contributions are public.</p>
|
<p class="text-faint text-xs">
|
||||||
|
By signing in, you agree that your contributions are public.
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
@import 'tailwindcss';
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-serif: 'EB Garamond', 'Iowan Old Style', Palatino, Georgia, serif;
|
--font-serif: "EB Garamond", "Iowan Old Style", Palatino, Georgia, serif;
|
||||||
--color-primary: #ea4c65;
|
--color-primary: #ea4c65;
|
||||||
--color-primary-dark: #d43d56;
|
--color-primary-dark: #d43d56;
|
||||||
--color-secondary: #00bcd4;
|
--color-secondary: #00bcd4;
|
||||||
|
|
@ -25,35 +25,35 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
.kicker {
|
.kicker {
|
||||||
@apply font-sans text-[11px] font-medium uppercase tracking-[0.18em] text-faint;
|
@apply text-faint font-sans text-[11px] font-medium tracking-[0.18em] uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply inline-flex items-center justify-center rounded-none border border-primary bg-primary px-5 py-2.5 text-[11px] font-medium uppercase tracking-[0.14em] text-white transition-colors hover:bg-primary-dark hover:border-primary-dark disabled:cursor-not-allowed disabled:opacity-50;
|
@apply border-primary bg-primary hover:bg-primary-dark hover:border-primary-dark inline-flex items-center justify-center rounded-none border px-5 py-2.5 text-[11px] font-medium tracking-[0.14em] text-white uppercase transition-colors disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply inline-flex items-center justify-center rounded-none border border-secondary bg-transparent px-5 py-2.5 text-[11px] font-medium uppercase tracking-[0.14em] text-secondary transition-colors hover:bg-secondary hover:text-white disabled:cursor-not-allowed disabled:opacity-50;
|
@apply border-secondary text-secondary hover:bg-secondary inline-flex items-center justify-center rounded-none border bg-transparent px-5 py-2.5 text-[11px] font-medium tracking-[0.14em] uppercase transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply inline-flex items-center justify-center rounded-none border border-ink bg-transparent px-5 py-2.5 text-[11px] font-medium uppercase tracking-[0.14em] text-ink transition-colors hover:border-primary hover:text-primary;
|
@apply border-ink text-ink hover:border-primary hover:text-primary inline-flex items-center justify-center rounded-none border bg-transparent px-5 py-2.5 text-[11px] font-medium tracking-[0.14em] uppercase transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@apply block w-full rounded-none border border-rule bg-white px-3 py-2.5 text-sm text-ink outline-none transition-colors placeholder:text-faint focus:border-primary;
|
@apply border-rule text-ink placeholder:text-faint focus:border-primary block w-full rounded-none border bg-white px-3 py-2.5 text-sm transition-colors outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
@apply inline-flex items-center rounded-none border border-rule bg-white px-3 py-1.5 font-sans text-[11px] font-medium uppercase tracking-[0.12em] text-ink transition-colors hover:border-ink;
|
@apply border-rule text-ink hover:border-ink inline-flex items-center rounded-none border bg-white px-3 py-1.5 font-sans text-[11px] font-medium tracking-[0.12em] uppercase transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-active {
|
.chip-active {
|
||||||
@apply border-primary bg-primary text-white hover:border-primary-dark hover:bg-primary-dark;
|
@apply border-primary bg-primary hover:border-primary-dark hover:bg-primary-dark text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@apply text-[11px] font-medium uppercase tracking-[0.2em] text-muted transition-colors hover:text-primary;
|
@apply text-muted hover:text-primary text-[11px] font-medium tracking-[0.2em] uppercase transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link-active {
|
.nav-link-active {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query } from '$lib/api';
|
import { query } from "$lib/api";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import FeedRow from '$lib/components/FeedRow.svelte';
|
import FeedRow from "$lib/components/FeedRow.svelte";
|
||||||
import { timeAgo } from '$lib/time';
|
import { timeAgo } from "$lib/time";
|
||||||
import type { NoteDoc, UnitDoc } from '$lib/types';
|
import type { NoteDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
let notes: (NoteDoc & { unit?: UnitDoc })[] = $state([]);
|
let notes: (NoteDoc & { unit?: UnitDoc })[] = $state([]);
|
||||||
let units: UnitDoc[] = $state([]);
|
let units: UnitDoc[] = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let selectedUnitId = $state('');
|
let selectedUnitId = $state("");
|
||||||
let sort = $state<'newest' | 'top'>('newest');
|
let sort = $state<"newest" | "top">("newest");
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const [n, u] = await Promise.all([query('notes:list', {}), query('units:getAll')]);
|
const [n, u] = await Promise.all([query("notes:list", {}), query("units:getAll")]);
|
||||||
const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit]));
|
const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit]));
|
||||||
notes = (n as NoteDoc[]).map((note) => ({ ...note, unit: unitMap.get(note.unitId) }));
|
notes = (n as NoteDoc[]).map((note) => ({ ...note, unit: unitMap.get(note.unitId) }));
|
||||||
units = u as UnitDoc[];
|
units = u as UnitDoc[];
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
const visible = $derived.by(() => {
|
const visible = $derived.by(() => {
|
||||||
let list = selectedUnitId ? notes.filter((n) => n.unitId === selectedUnitId) : notes;
|
let list = selectedUnitId ? notes.filter((n) => n.unitId === selectedUnitId) : notes;
|
||||||
if (sort === 'top') list = [...list].sort((a, b) => b.voteCount - a.voteCount);
|
if (sort === "top") list = [...list].sort((a, b) => b.voteCount - a.voteCount);
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -36,17 +36,17 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="flex items-end justify-between gap-4 border-b border-rule pb-6">
|
<div class="border-rule flex items-end justify-between gap-4 border-b pb-6">
|
||||||
<h1 class="font-serif text-4xl font-medium text-ink">Notes</h1>
|
<h1 class="text-ink font-serif text-4xl font-medium">Notes</h1>
|
||||||
<a href="/post/note" class="btn-primary">Post a note</a>
|
<a href="/post/note" class="btn-primary">Post a note</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-rule py-4">
|
<div class="border-rule flex flex-wrap items-center justify-between gap-4 border-b py-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
||||||
onclick={() => (selectedUnitId = '')}
|
onclick={() => (selectedUnitId = "")}
|
||||||
>
|
>
|
||||||
All units
|
All units
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -64,14 +64,14 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="kicker {sort === 'newest' ? 'text-ink' : ''}"
|
class="kicker {sort === 'newest' ? 'text-ink' : ''}"
|
||||||
onclick={() => (sort = 'newest')}
|
onclick={() => (sort = "newest")}
|
||||||
>
|
>
|
||||||
Newest
|
Newest
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="kicker {sort === 'top' ? 'text-ink' : ''}"
|
class="kicker {sort === 'top' ? 'text-ink' : ''}"
|
||||||
onclick={() => (sort = 'top')}
|
onclick={() => (sort = "top")}
|
||||||
>
|
>
|
||||||
Top
|
Top
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -86,15 +86,21 @@
|
||||||
href="/notes/{note._id}"
|
href="/notes/{note._id}"
|
||||||
title={note.title}
|
title={note.title}
|
||||||
unitCode={note.unit?.code}
|
unitCode={note.unit?.code}
|
||||||
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
meta="{note.authorName} · {timeAgo(
|
||||||
|
note.createdAt,
|
||||||
|
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
||||||
voteCount={note.voteCount}
|
voteCount={note.voteCount}
|
||||||
targetType="note"
|
targetType="note"
|
||||||
targetId={note._id}
|
targetId={note._id}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="py-16">
|
<div class="py-16">
|
||||||
<p class="text-sm text-muted">No notes yet.</p>
|
<p class="text-muted text-sm">No notes yet.</p>
|
||||||
<a href="/post/note" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Post a note</a>
|
<a
|
||||||
|
href="/post/note"
|
||||||
|
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
|
||||||
|
>Post a note</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query, mutation } from '$lib/api';
|
import { query, mutation } from "$lib/api";
|
||||||
import { isAuthenticated, getToken, currentUser } from '$lib/stores/auth';
|
import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth";
|
||||||
import { page } from '$app/state';
|
import { page } from "$app/state";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import { get } from 'svelte/store';
|
import { get } from "svelte/store";
|
||||||
import VoteStack from '$lib/components/VoteStack.svelte';
|
import VoteStack from "$lib/components/VoteStack.svelte";
|
||||||
import { timeAgo } from '$lib/time';
|
import { timeAgo } from "$lib/time";
|
||||||
import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from '$lib/types';
|
import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
let note: NoteDoc | null = $state(null);
|
let note: NoteDoc | null = $state(null);
|
||||||
let topic: TopicDoc | null = $state(null);
|
let topic: TopicDoc | null = $state(null);
|
||||||
|
|
@ -14,15 +14,15 @@
|
||||||
let comments: CommentDoc[] = $state([]);
|
let comments: CommentDoc[] = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
let commentText = $state('');
|
let commentText = $state("");
|
||||||
let commentError = $state('');
|
let commentError = $state("");
|
||||||
let commentLoading = $state(false);
|
let commentLoading = $state(false);
|
||||||
let deleteLoading = $state(false);
|
let deleteLoading = $state(false);
|
||||||
let isAuthor = $state(false);
|
let isAuthor = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const id = page.params.id;
|
const id = page.params.id;
|
||||||
const result = await query('details:getNoteWithDetails', { id });
|
const result = await query("details:getNoteWithDetails", { id });
|
||||||
if (result) {
|
if (result) {
|
||||||
note = result as NoteDoc;
|
note = result as NoteDoc;
|
||||||
topic = (result as any).topic ?? null;
|
topic = (result as any).topic ?? null;
|
||||||
|
|
@ -38,23 +38,23 @@
|
||||||
if (!commentText.trim()) return;
|
if (!commentText.trim()) return;
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
commentError = 'Please sign in to comment';
|
commentError = "Please sign in to comment";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
commentLoading = true;
|
commentLoading = true;
|
||||||
commentError = '';
|
commentError = "";
|
||||||
try {
|
try {
|
||||||
await mutation('comments:createOnNote', {
|
await mutation("comments:createOnNote", {
|
||||||
token,
|
token,
|
||||||
content: commentText.trim(),
|
content: commentText.trim(),
|
||||||
parentId: page.params.id
|
parentId: page.params.id,
|
||||||
});
|
});
|
||||||
commentText = '';
|
commentText = "";
|
||||||
const updated = await query('details:getNoteWithDetails', { id: page.params.id });
|
const updated = await query("details:getNoteWithDetails", { id: page.params.id });
|
||||||
if (updated) comments = (updated as any).comments ?? [];
|
if (updated) comments = (updated as any).comments ?? [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
commentError = err.message ?? 'Failed to post comment';
|
commentError = err.message ?? "Failed to post comment";
|
||||||
} finally {
|
} finally {
|
||||||
commentLoading = false;
|
commentLoading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -63,14 +63,14 @@
|
||||||
async function deleteNote() {
|
async function deleteNote() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !note) return;
|
if (!token || !note) return;
|
||||||
if (!confirm('Delete this note?')) return;
|
if (!confirm("Delete this note?")) return;
|
||||||
|
|
||||||
deleteLoading = true;
|
deleteLoading = true;
|
||||||
try {
|
try {
|
||||||
await mutation('notes:remove', { token, id: note._id });
|
await mutation("notes:remove", { token, id: note._id });
|
||||||
window.location.href = '/notes';
|
window.location.href = "/notes";
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message ?? 'Failed to delete');
|
alert(err.message ?? "Failed to delete");
|
||||||
} finally {
|
} finally {
|
||||||
deleteLoading = false;
|
deleteLoading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{note?.title ?? 'Loading...'} — Notebook</title>
|
<title>{note?.title ?? "Loading..."} — Notebook</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -89,9 +89,13 @@
|
||||||
<VoteStack count={note.voteCount} targetType="note" targetId={note._id} />
|
<VoteStack count={note.voteCount} targetType="note" targetId={note._id} />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="kicker">
|
<p class="kicker">
|
||||||
{#if unit}{unit.code}{/if}{#if unit && topic} · {/if}{#if topic}{topic.name}{/if}
|
{#if unit}{unit.code}{/if}{#if unit && topic}
|
||||||
|
·
|
||||||
|
{/if}{#if topic}{topic.name}{/if}
|
||||||
</p>
|
</p>
|
||||||
<h1 class="mt-2 font-serif text-3xl font-medium leading-tight text-ink sm:text-4xl">{note.title}</h1>
|
<h1 class="text-ink mt-2 font-serif text-3xl leading-tight font-medium sm:text-4xl">
|
||||||
|
{note.title}
|
||||||
|
</h1>
|
||||||
<p class="kicker mt-3">
|
<p class="kicker mt-3">
|
||||||
{note.authorName} · {timeAgo(note.createdAt)}
|
{note.authorName} · {timeAgo(note.createdAt)}
|
||||||
{#if isAuthor}
|
{#if isAuthor}
|
||||||
|
|
@ -102,68 +106,86 @@
|
||||||
disabled={deleteLoading}
|
disabled={deleteLoading}
|
||||||
class="text-primary hover:text-primary-dark"
|
class="text-primary hover:text-primary-dark"
|
||||||
>
|
>
|
||||||
{deleteLoading ? 'Deleting...' : 'Delete'}
|
{deleteLoading ? "Deleting..." : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 whitespace-pre-wrap border-t border-rule pt-8 text-[15px] leading-relaxed text-ink">
|
<div
|
||||||
|
class="border-rule text-ink mt-8 border-t pt-8 text-[15px] leading-relaxed whitespace-pre-wrap"
|
||||||
|
>
|
||||||
{note.content}
|
{note.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="mt-12 border-t border-rule pt-8">
|
<section class="border-rule mt-12 border-t pt-8">
|
||||||
<p class="kicker mb-6">Comments ({comments.length})</p>
|
<p class="kicker mb-6">Comments ({comments.length})</p>
|
||||||
|
|
||||||
{#if get(isAuthenticated)}
|
{#if get(isAuthenticated)}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<textarea bind:value={commentText} rows={3} placeholder="Add a comment..." class="field resize-y"></textarea>
|
<textarea
|
||||||
|
bind:value={commentText}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
class="field resize-y"></textarea>
|
||||||
<div class="mt-3 flex items-center justify-between">
|
<div class="mt-3 flex items-center justify-between">
|
||||||
<span class="text-xs text-primary">{commentError}</span>
|
<span class="text-primary text-xs">{commentError}</span>
|
||||||
<button
|
<button
|
||||||
onclick={postComment}
|
onclick={postComment}
|
||||||
disabled={commentLoading || !commentText.trim()}
|
disabled={commentLoading || !commentText.trim()}
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
>
|
>
|
||||||
{commentLoading ? 'Posting...' : 'Post comment'}
|
{commentLoading ? "Posting..." : "Post comment"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mb-8 text-sm text-muted">
|
<p class="text-muted mb-8 text-sm">
|
||||||
<a href="/auth/login" class="text-secondary hover:text-secondary-dark">Sign in</a> to leave a comment.
|
<a href="/auth/login" class="text-secondary hover:text-secondary-dark"
|
||||||
|
>Sign in</a
|
||||||
|
> to leave a comment.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#each comments as comment}
|
{#each comments as comment}
|
||||||
<div class="border-t border-rule py-4">
|
<div class="border-rule border-t py-4">
|
||||||
<p class="kicker">{comment.authorName} · {timeAgo(comment.createdAt)}</p>
|
<p class="kicker">{comment.authorName} · {timeAgo(comment.createdAt)}</p>
|
||||||
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">{comment.content}</p>
|
<p class="text-ink mt-2 text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
{#if get(currentUser)?._id === comment.authorId}
|
{#if get(currentUser)?._id === comment.authorId}
|
||||||
<button
|
<button
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
await mutation('comments:remove', { token, id: comment._id });
|
await mutation("comments:remove", {
|
||||||
const updated = await query('details:getNoteWithDetails', { id: page.params.id });
|
token,
|
||||||
|
id: comment._id,
|
||||||
|
});
|
||||||
|
const updated = await query("details:getNoteWithDetails", {
|
||||||
|
id: page.params.id,
|
||||||
|
});
|
||||||
if (updated) comments = (updated as any).comments ?? [];
|
if (updated) comments = (updated as any).comments ?? [];
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}}
|
}}
|
||||||
class="mt-2 text-xs text-primary hover:text-primary-dark"
|
class="text-primary hover:text-primary-dark mt-2 text-xs"
|
||||||
>Delete</button>
|
>Delete</button
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-muted">No comments yet.</p>
|
<p class="text-muted text-sm">No comments yet.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="font-serif text-3xl text-ink">Note not found</h1>
|
<h1 class="text-ink font-serif text-3xl">Note not found</h1>
|
||||||
<p class="mt-2 text-sm text-muted">This note may have been deleted or doesn't exist.</p>
|
<p class="text-muted mt-2 text-sm">This note may have been deleted or doesn't exist.</p>
|
||||||
<a href="/notes" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Back to notes</a>
|
<a href="/notes" class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
|
||||||
|
>Back to notes</a
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,50 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query, mutation } from '$lib/api';
|
import { query, mutation } from "$lib/api";
|
||||||
import { isAuthenticated, getToken } from '$lib/stores/auth';
|
import { isAuthenticated, getToken } from "$lib/stores/auth";
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from "$app/navigation";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import { get } from 'svelte/store';
|
import { get } from "svelte/store";
|
||||||
import type { TopicDoc, UnitDoc } from '$lib/types';
|
import type { TopicDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
let topics: TopicDoc[] = $state([]);
|
let topics: TopicDoc[] = $state([]);
|
||||||
let units: UnitDoc[] = $state([]);
|
let units: UnitDoc[] = $state([]);
|
||||||
let title = $state('');
|
let title = $state("");
|
||||||
let content = $state('');
|
let content = $state("");
|
||||||
let selectedTopicId = $state('');
|
let selectedTopicId = $state("");
|
||||||
let selectedUnitId = $state('');
|
let selectedUnitId = $state("");
|
||||||
let customUnit = $state('');
|
let customUnit = $state("");
|
||||||
let useCustomUnit = $state(false);
|
let useCustomUnit = $state(false);
|
||||||
let error = $state('');
|
let error = $state("");
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let success = $state('');
|
let success = $state("");
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!get(isAuthenticated)) {
|
if (!get(isAuthenticated)) {
|
||||||
goto('/auth/login');
|
goto("/auth/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [t, u] = await Promise.all([query('topics:getAll'), query('units:getAll')]);
|
const [t, u] = await Promise.all([query("topics:getAll"), query("units:getAll")]);
|
||||||
topics = t as TopicDoc[];
|
topics = t as TopicDoc[];
|
||||||
units = u as UnitDoc[];
|
units = u as UnitDoc[];
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = "";
|
||||||
success = '';
|
success = "";
|
||||||
|
|
||||||
if (!title.trim() || !content.trim()) {
|
if (!title.trim() || !content.trim()) {
|
||||||
error = 'Title and content are required';
|
error = "Title and content are required";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedTopicId) {
|
if (!selectedTopicId) {
|
||||||
error = 'Please select a topic';
|
error = "Please select a topic";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
error = 'You must be signed in';
|
error = "You must be signed in";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,38 +52,40 @@
|
||||||
try {
|
try {
|
||||||
let unitId = selectedUnitId;
|
let unitId = selectedUnitId;
|
||||||
if (useCustomUnit && customUnit.trim()) {
|
if (useCustomUnit && customUnit.trim()) {
|
||||||
const existing = units.find((u) => u.code.toLowerCase() === customUnit.trim().toUpperCase());
|
const existing = units.find(
|
||||||
|
(u) => u.code.toLowerCase() === customUnit.trim().toUpperCase(),
|
||||||
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
unitId = existing._id;
|
unitId = existing._id;
|
||||||
} else {
|
} else {
|
||||||
unitId = (await mutation('units:createCustom', {
|
unitId = (await mutation("units:createCustom", {
|
||||||
code: customUnit.trim().toUpperCase(),
|
code: customUnit.trim().toUpperCase(),
|
||||||
name: customUnit.trim().toUpperCase()
|
name: customUnit.trim().toUpperCase(),
|
||||||
})) as string;
|
})) as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutation('notes:create', {
|
await mutation("notes:create", {
|
||||||
token,
|
token,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
topicId: selectedTopicId,
|
topicId: selectedTopicId,
|
||||||
unitId
|
unitId,
|
||||||
});
|
});
|
||||||
|
|
||||||
success = 'Note published.';
|
success = "Note published.";
|
||||||
title = '';
|
title = "";
|
||||||
content = '';
|
content = "";
|
||||||
selectedTopicId = '';
|
selectedTopicId = "";
|
||||||
selectedUnitId = '';
|
selectedUnitId = "";
|
||||||
customUnit = '';
|
customUnit = "";
|
||||||
useCustomUnit = false;
|
useCustomUnit = false;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
success = '';
|
success = "";
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err.message ?? 'Failed to publish note';
|
error = err.message ?? "Failed to publish note";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -95,16 +97,16 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1 class="font-serif text-4xl font-medium text-ink">Post a note</h1>
|
<h1 class="text-ink font-serif text-4xl font-medium">Post a note</h1>
|
||||||
<p class="mt-2 mb-10 text-[15px] text-muted">
|
<p class="text-muted mt-2 mb-10 text-[15px]">
|
||||||
Share study notes with the Deakin community. Focus on a specific topic, not an entire unit.
|
Share study notes with the Deakin community. Focus on a specific topic, not an entire unit.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if success}
|
{#if success}
|
||||||
<p class="mb-6 text-sm text-secondary">{success}</p>
|
<p class="text-secondary mb-6 text-sm">{success}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-6 border-t border-rule pt-8">
|
<form onsubmit={handleSubmit} class="border-rule space-y-6 border-t pt-8">
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="kicker mb-2 block">Title</label>
|
<label for="title" class="kicker mb-2 block">Title</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -125,8 +127,7 @@
|
||||||
rows={10}
|
rows={10}
|
||||||
placeholder="Write your notes here..."
|
placeholder="Write your notes here..."
|
||||||
class="field resize-y"
|
class="field resize-y"
|
||||||
required
|
required></textarea>
|
||||||
></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
|
@ -155,25 +156,30 @@
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
useCustomUnit = !useCustomUnit;
|
useCustomUnit = !useCustomUnit;
|
||||||
customUnit = '';
|
customUnit = "";
|
||||||
}}
|
}}
|
||||||
class="mt-2 text-xs text-secondary hover:text-secondary-dark"
|
class="text-secondary hover:text-secondary-dark mt-2 text-xs"
|
||||||
>
|
>
|
||||||
{useCustomUnit ? 'Choose from list' : 'Other unit...'}
|
{useCustomUnit ? "Choose from list" : "Other unit..."}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if useCustomUnit}
|
{#if useCustomUnit}
|
||||||
<input type="text" bind:value={customUnit} placeholder="e.g., SIT384" class="field mt-2" />
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUnit}
|
||||||
|
placeholder="e.g., SIT384"
|
||||||
|
class="field mt-2"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-sm text-primary">{error}</p>
|
<p class="text-primary text-sm">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} class="btn-primary">
|
<button type="submit" disabled={loading} class="btn-primary">
|
||||||
{loading ? 'Publishing...' : 'Publish note'}
|
{loading ? "Publishing..." : "Publish note"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,50 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query, mutation } from '$lib/api';
|
import { query, mutation } from "$lib/api";
|
||||||
import { isAuthenticated, getToken } from '$lib/stores/auth';
|
import { isAuthenticated, getToken } from "$lib/stores/auth";
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from "$app/navigation";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import { get } from 'svelte/store';
|
import { get } from "svelte/store";
|
||||||
import type { TopicDoc, UnitDoc } from '$lib/types';
|
import type { TopicDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
let topics: TopicDoc[] = $state([]);
|
let topics: TopicDoc[] = $state([]);
|
||||||
let units: UnitDoc[] = $state([]);
|
let units: UnitDoc[] = $state([]);
|
||||||
let title = $state('');
|
let title = $state("");
|
||||||
let content = $state('');
|
let content = $state("");
|
||||||
let selectedTopicId = $state('');
|
let selectedTopicId = $state("");
|
||||||
let selectedUnitId = $state('');
|
let selectedUnitId = $state("");
|
||||||
let customUnit = $state('');
|
let customUnit = $state("");
|
||||||
let useCustomUnit = $state(false);
|
let useCustomUnit = $state(false);
|
||||||
let error = $state('');
|
let error = $state("");
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let success = $state('');
|
let success = $state("");
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!get(isAuthenticated)) {
|
if (!get(isAuthenticated)) {
|
||||||
goto('/auth/login');
|
goto("/auth/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [t, u] = await Promise.all([query('topics:getAll'), query('units:getAll')]);
|
const [t, u] = await Promise.all([query("topics:getAll"), query("units:getAll")]);
|
||||||
topics = t as TopicDoc[];
|
topics = t as TopicDoc[];
|
||||||
units = u as UnitDoc[];
|
units = u as UnitDoc[];
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = "";
|
||||||
success = '';
|
success = "";
|
||||||
|
|
||||||
if (!title.trim() || !content.trim()) {
|
if (!title.trim() || !content.trim()) {
|
||||||
error = 'Title and content are required';
|
error = "Title and content are required";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedTopicId) {
|
if (!selectedTopicId) {
|
||||||
error = 'Please select a topic';
|
error = "Please select a topic";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
error = 'You must be signed in';
|
error = "You must be signed in";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,38 +52,40 @@
|
||||||
try {
|
try {
|
||||||
let unitId = selectedUnitId;
|
let unitId = selectedUnitId;
|
||||||
if (useCustomUnit && customUnit.trim()) {
|
if (useCustomUnit && customUnit.trim()) {
|
||||||
const existing = units.find((u) => u.code.toLowerCase() === customUnit.trim().toUpperCase());
|
const existing = units.find(
|
||||||
|
(u) => u.code.toLowerCase() === customUnit.trim().toUpperCase(),
|
||||||
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
unitId = existing._id;
|
unitId = existing._id;
|
||||||
} else {
|
} else {
|
||||||
unitId = (await mutation('units:createCustom', {
|
unitId = (await mutation("units:createCustom", {
|
||||||
code: customUnit.trim().toUpperCase(),
|
code: customUnit.trim().toUpperCase(),
|
||||||
name: customUnit.trim().toUpperCase()
|
name: customUnit.trim().toUpperCase(),
|
||||||
})) as string;
|
})) as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutation('questions:create', {
|
await mutation("questions:create", {
|
||||||
token,
|
token,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
topicId: selectedTopicId,
|
topicId: selectedTopicId,
|
||||||
unitId
|
unitId,
|
||||||
});
|
});
|
||||||
|
|
||||||
success = 'Question posted.';
|
success = "Question posted.";
|
||||||
title = '';
|
title = "";
|
||||||
content = '';
|
content = "";
|
||||||
selectedTopicId = '';
|
selectedTopicId = "";
|
||||||
selectedUnitId = '';
|
selectedUnitId = "";
|
||||||
customUnit = '';
|
customUnit = "";
|
||||||
useCustomUnit = false;
|
useCustomUnit = false;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
success = '';
|
success = "";
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err.message ?? 'Failed to post question';
|
error = err.message ?? "Failed to post question";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -95,14 +97,16 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1 class="font-serif text-4xl font-medium text-ink">Ask a question</h1>
|
<h1 class="text-ink font-serif text-4xl font-medium">Ask a question</h1>
|
||||||
<p class="mt-2 mb-10 text-[15px] text-muted">Stuck on something? Ask the Deakin community for help.</p>
|
<p class="text-muted mt-2 mb-10 text-[15px]">
|
||||||
|
Stuck on something? Ask the Deakin community for help.
|
||||||
|
</p>
|
||||||
|
|
||||||
{#if success}
|
{#if success}
|
||||||
<p class="mb-6 text-sm text-secondary">{success}</p>
|
<p class="text-secondary mb-6 text-sm">{success}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-6 border-t border-rule pt-8">
|
<form onsubmit={handleSubmit} class="border-rule space-y-6 border-t pt-8">
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="kicker mb-2 block">Question title</label>
|
<label for="title" class="kicker mb-2 block">Question title</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -123,8 +127,7 @@
|
||||||
rows={10}
|
rows={10}
|
||||||
placeholder="Describe your question in detail..."
|
placeholder="Describe your question in detail..."
|
||||||
class="field resize-y"
|
class="field resize-y"
|
||||||
required
|
required></textarea>
|
||||||
></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
|
@ -153,25 +156,30 @@
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
useCustomUnit = !useCustomUnit;
|
useCustomUnit = !useCustomUnit;
|
||||||
customUnit = '';
|
customUnit = "";
|
||||||
}}
|
}}
|
||||||
class="mt-2 text-xs text-secondary hover:text-secondary-dark"
|
class="text-secondary hover:text-secondary-dark mt-2 text-xs"
|
||||||
>
|
>
|
||||||
{useCustomUnit ? 'Choose from list' : 'Other unit...'}
|
{useCustomUnit ? "Choose from list" : "Other unit..."}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if useCustomUnit}
|
{#if useCustomUnit}
|
||||||
<input type="text" bind:value={customUnit} placeholder="e.g., SIT384" class="field mt-2" />
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUnit}
|
||||||
|
placeholder="e.g., SIT384"
|
||||||
|
class="field mt-2"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-sm text-primary">{error}</p>
|
<p class="text-primary text-sm">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} class="btn-primary">
|
<button type="submit" disabled={loading} class="btn-primary">
|
||||||
{loading ? 'Posting...' : 'Post question'}
|
{loading ? "Posting..." : "Post question"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query } from '$lib/api';
|
import { query } from "$lib/api";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import FeedRow from '$lib/components/FeedRow.svelte';
|
import FeedRow from "$lib/components/FeedRow.svelte";
|
||||||
import { timeAgo } from '$lib/time';
|
import { timeAgo } from "$lib/time";
|
||||||
import type { QuestionDoc, UnitDoc } from '$lib/types';
|
import type { QuestionDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
let questions: (QuestionDoc & { unit?: UnitDoc })[] = $state([]);
|
let questions: (QuestionDoc & { unit?: UnitDoc })[] = $state([]);
|
||||||
let units: UnitDoc[] = $state([]);
|
let units: UnitDoc[] = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let selectedUnitId = $state('');
|
let selectedUnitId = $state("");
|
||||||
let sort = $state<'newest' | 'top'>('newest');
|
let sort = $state<"newest" | "top">("newest");
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const [q, u] = await Promise.all([query('questions:list', {}), query('units:getAll')]);
|
const [q, u] = await Promise.all([query("questions:list", {}), query("units:getAll")]);
|
||||||
const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit]));
|
const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit]));
|
||||||
questions = (q as QuestionDoc[]).map((question) => ({
|
questions = (q as QuestionDoc[]).map((question) => ({
|
||||||
...question,
|
...question,
|
||||||
unit: unitMap.get(question.unitId)
|
unit: unitMap.get(question.unitId),
|
||||||
}));
|
}));
|
||||||
units = u as UnitDoc[];
|
units = u as UnitDoc[];
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
@ -28,8 +28,10 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const visible = $derived.by(() => {
|
const visible = $derived.by(() => {
|
||||||
let list = selectedUnitId ? questions.filter((q) => q.unitId === selectedUnitId) : questions;
|
let list = selectedUnitId
|
||||||
if (sort === 'top') list = [...list].sort((a, b) => b.voteCount - a.voteCount);
|
? questions.filter((q) => q.unitId === selectedUnitId)
|
||||||
|
: questions;
|
||||||
|
if (sort === "top") list = [...list].sort((a, b) => b.voteCount - a.voteCount);
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -39,17 +41,17 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="flex items-end justify-between gap-4 border-b border-rule pb-6">
|
<div class="border-rule flex items-end justify-between gap-4 border-b pb-6">
|
||||||
<h1 class="font-serif text-4xl font-medium text-ink">Questions</h1>
|
<h1 class="text-ink font-serif text-4xl font-medium">Questions</h1>
|
||||||
<a href="/post/question" class="btn-primary">Ask a question</a>
|
<a href="/post/question" class="btn-primary">Ask a question</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-rule py-4">
|
<div class="border-rule flex flex-wrap items-center justify-between gap-4 border-b py-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
||||||
onclick={() => (selectedUnitId = '')}
|
onclick={() => (selectedUnitId = "")}
|
||||||
>
|
>
|
||||||
All units
|
All units
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -67,14 +69,14 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="kicker {sort === 'newest' ? 'text-ink' : ''}"
|
class="kicker {sort === 'newest' ? 'text-ink' : ''}"
|
||||||
onclick={() => (sort = 'newest')}
|
onclick={() => (sort = "newest")}
|
||||||
>
|
>
|
||||||
Newest
|
Newest
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="kicker {sort === 'top' ? 'text-ink' : ''}"
|
class="kicker {sort === 'top' ? 'text-ink' : ''}"
|
||||||
onclick={() => (sort = 'top')}
|
onclick={() => (sort = "top")}
|
||||||
>
|
>
|
||||||
Top
|
Top
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -89,15 +91,21 @@
|
||||||
href="/questions/{question._id}"
|
href="/questions/{question._id}"
|
||||||
title={question.title}
|
title={question.title}
|
||||||
unitCode={question.unit?.code}
|
unitCode={question.unit?.code}
|
||||||
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
meta="{question.authorName} · {timeAgo(
|
||||||
|
question.createdAt,
|
||||||
|
)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
||||||
voteCount={question.voteCount}
|
voteCount={question.voteCount}
|
||||||
targetType="question"
|
targetType="question"
|
||||||
targetId={question._id}
|
targetId={question._id}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="py-16">
|
<div class="py-16">
|
||||||
<p class="text-sm text-muted">No questions yet.</p>
|
<p class="text-muted text-sm">No questions yet.</p>
|
||||||
<a href="/post/question" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Ask a question</a>
|
<a
|
||||||
|
href="/post/question"
|
||||||
|
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
|
||||||
|
>Ask a question</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query, mutation } from '$lib/api';
|
import { query, mutation } from "$lib/api";
|
||||||
import { isAuthenticated, getToken, currentUser } from '$lib/stores/auth';
|
import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth";
|
||||||
import { page } from '$app/state';
|
import { page } from "$app/state";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import { get } from 'svelte/store';
|
import { get } from "svelte/store";
|
||||||
import VoteStack from '$lib/components/VoteStack.svelte';
|
import VoteStack from "$lib/components/VoteStack.svelte";
|
||||||
import { timeAgo } from '$lib/time';
|
import { timeAgo } from "$lib/time";
|
||||||
import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from '$lib/types';
|
import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
let question: QuestionDoc | null = $state(null);
|
let question: QuestionDoc | null = $state(null);
|
||||||
let topic: TopicDoc | null = $state(null);
|
let topic: TopicDoc | null = $state(null);
|
||||||
|
|
@ -15,13 +15,13 @@
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let isAuthor = $state(false);
|
let isAuthor = $state(false);
|
||||||
|
|
||||||
let answerText = $state('');
|
let answerText = $state("");
|
||||||
let answerError = $state('');
|
let answerError = $state("");
|
||||||
let answerLoading = $state(false);
|
let answerLoading = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const id = page.params.id;
|
const id = page.params.id;
|
||||||
const result = await query('details:getQuestionWithDetails', { id });
|
const result = await query("details:getQuestionWithDetails", { id });
|
||||||
if (result) {
|
if (result) {
|
||||||
question = result as QuestionDoc;
|
question = result as QuestionDoc;
|
||||||
topic = (result as any).topic ?? null;
|
topic = (result as any).topic ?? null;
|
||||||
|
|
@ -37,26 +37,26 @@
|
||||||
if (!answerText.trim()) return;
|
if (!answerText.trim()) return;
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
answerError = 'Please sign in to answer';
|
answerError = "Please sign in to answer";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
answerLoading = true;
|
answerLoading = true;
|
||||||
answerError = '';
|
answerError = "";
|
||||||
try {
|
try {
|
||||||
await mutation('comments:createOnQuestion', {
|
await mutation("comments:createOnQuestion", {
|
||||||
token,
|
token,
|
||||||
content: answerText.trim(),
|
content: answerText.trim(),
|
||||||
questionId: page.params.id
|
questionId: page.params.id,
|
||||||
});
|
});
|
||||||
answerText = '';
|
answerText = "";
|
||||||
const updated = await query('details:getQuestionWithDetails', { id: page.params.id });
|
const updated = await query("details:getQuestionWithDetails", { id: page.params.id });
|
||||||
if (updated) {
|
if (updated) {
|
||||||
question = { ...(updated as any), answers: undefined } as QuestionDoc;
|
question = { ...(updated as any), answers: undefined } as QuestionDoc;
|
||||||
answers = (updated as any).answers ?? [];
|
answers = (updated as any).answers ?? [];
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
answerError = err.message ?? 'Failed to post answer';
|
answerError = err.message ?? "Failed to post answer";
|
||||||
} finally {
|
} finally {
|
||||||
answerLoading = false;
|
answerLoading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -66,16 +66,16 @@
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !question) return;
|
if (!token || !question) return;
|
||||||
try {
|
try {
|
||||||
await mutation('questions:markSolved', { token, id: question._id });
|
await mutation("questions:markSolved", { token, id: question._id });
|
||||||
question = { ...question, solved: true };
|
question = { ...question, solved: true };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message ?? 'Failed');
|
alert(err.message ?? "Failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{question?.title ?? 'Loading...'} — Notebook</title>
|
<title>{question?.title ?? "Loading..."} — Notebook</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -86,15 +86,24 @@
|
||||||
<VoteStack count={question.voteCount} targetType="question" targetId={question._id} />
|
<VoteStack count={question.voteCount} targetType="question" targetId={question._id} />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="kicker">
|
<p class="kicker">
|
||||||
{#if unit}{unit.code}{/if}{#if unit && topic} · {/if}{#if topic}{topic.name}{/if}
|
{#if unit}{unit.code}{/if}{#if unit && topic}
|
||||||
{#if question.solved} · Solved{/if}
|
·
|
||||||
|
{/if}{#if topic}{topic.name}{/if}
|
||||||
|
{#if question.solved}
|
||||||
|
· Solved{/if}
|
||||||
</p>
|
</p>
|
||||||
<h1 class="mt-2 font-serif text-3xl font-medium leading-tight text-ink sm:text-4xl">{question.title}</h1>
|
<h1 class="text-ink mt-2 font-serif text-3xl leading-tight font-medium sm:text-4xl">
|
||||||
|
{question.title}
|
||||||
|
</h1>
|
||||||
<p class="kicker mt-3">
|
<p class="kicker mt-3">
|
||||||
{question.authorName} · {timeAgo(question.createdAt)}
|
{question.authorName} · {timeAgo(question.createdAt)}
|
||||||
{#if isAuthor && !question.solved}
|
{#if isAuthor && !question.solved}
|
||||||
·
|
·
|
||||||
<button type="button" onclick={markSolved} class="text-secondary hover:text-secondary-dark">
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={markSolved}
|
||||||
|
class="text-secondary hover:text-secondary-dark"
|
||||||
|
>
|
||||||
Mark as solved
|
Mark as solved
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -102,61 +111,82 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 whitespace-pre-wrap border-t border-rule pt-8 text-[15px] leading-relaxed text-ink">
|
<div
|
||||||
|
class="border-rule text-ink mt-8 border-t pt-8 text-[15px] leading-relaxed whitespace-pre-wrap"
|
||||||
|
>
|
||||||
{question.content}
|
{question.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="mt-12 border-t border-rule pt-8">
|
<section class="border-rule mt-12 border-t pt-8">
|
||||||
<p class="kicker mb-6">Answers ({answers.length})</p>
|
<p class="kicker mb-6">Answers ({answers.length})</p>
|
||||||
|
|
||||||
{#if get(isAuthenticated)}
|
{#if get(isAuthenticated)}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<textarea bind:value={answerText} rows={4} placeholder="Write an answer..." class="field resize-y"></textarea>
|
<textarea
|
||||||
|
bind:value={answerText}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Write an answer..."
|
||||||
|
class="field resize-y"></textarea>
|
||||||
<div class="mt-3 flex items-center justify-between">
|
<div class="mt-3 flex items-center justify-between">
|
||||||
<span class="text-xs text-primary">{answerError}</span>
|
<span class="text-primary text-xs">{answerError}</span>
|
||||||
<button
|
<button
|
||||||
onclick={postAnswer}
|
onclick={postAnswer}
|
||||||
disabled={answerLoading || !answerText.trim()}
|
disabled={answerLoading || !answerText.trim()}
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
>
|
>
|
||||||
{answerLoading ? 'Posting...' : 'Post answer'}
|
{answerLoading ? "Posting..." : "Post answer"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mb-8 text-sm text-muted">
|
<p class="text-muted mb-8 text-sm">
|
||||||
<a href="/auth/login" class="text-secondary hover:text-secondary-dark">Sign in</a> to answer.
|
<a href="/auth/login" class="text-secondary hover:text-secondary-dark"
|
||||||
|
>Sign in</a
|
||||||
|
> to answer.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#each answers as answer}
|
{#each answers as answer}
|
||||||
<div class="border-t border-rule py-4">
|
<div class="border-rule border-t py-4">
|
||||||
<p class="kicker">{answer.authorName} · {timeAgo(answer.createdAt)}</p>
|
<p class="kicker">{answer.authorName} · {timeAgo(answer.createdAt)}</p>
|
||||||
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">{answer.content}</p>
|
<p class="text-ink mt-2 text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{answer.content}
|
||||||
|
</p>
|
||||||
{#if get(currentUser)?._id === answer.authorId}
|
{#if get(currentUser)?._id === answer.authorId}
|
||||||
<button
|
<button
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
await mutation('comments:remove', { token, id: answer._id });
|
await mutation("comments:remove", {
|
||||||
const updated = await query('details:getQuestionWithDetails', { id: page.params.id });
|
token,
|
||||||
|
id: answer._id,
|
||||||
|
});
|
||||||
|
const updated = await query(
|
||||||
|
"details:getQuestionWithDetails",
|
||||||
|
{ id: page.params.id },
|
||||||
|
);
|
||||||
if (updated) answers = (updated as any).answers ?? [];
|
if (updated) answers = (updated as any).answers ?? [];
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}}
|
}}
|
||||||
class="mt-2 text-xs text-primary hover:text-primary-dark"
|
class="text-primary hover:text-primary-dark mt-2 text-xs"
|
||||||
>Delete</button>
|
>Delete</button
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-muted">No answers yet.</p>
|
<p class="text-muted text-sm">No answers yet.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="font-serif text-3xl text-ink">Question not found</h1>
|
<h1 class="text-ink font-serif text-3xl">Question not found</h1>
|
||||||
<p class="mt-2 text-sm text-muted">This question may have been deleted or doesn't exist.</p>
|
<p class="text-muted mt-2 text-sm">This question may have been deleted or doesn't exist.</p>
|
||||||
<a href="/questions" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Back to questions</a>
|
<a
|
||||||
|
href="/questions"
|
||||||
|
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
|
||||||
|
>Back to questions</a
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,33 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query } from '$lib/api';
|
import { query } from "$lib/api";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import FeedRow from '$lib/components/FeedRow.svelte';
|
import FeedRow from "$lib/components/FeedRow.svelte";
|
||||||
import { timeAgo } from '$lib/time';
|
import { timeAgo } from "$lib/time";
|
||||||
import type { NoteDoc } from '$lib/types';
|
import type { NoteDoc } from "$lib/types";
|
||||||
|
|
||||||
let results: NoteDoc[] = $state([]);
|
let results: NoteDoc[] = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state("");
|
||||||
let ranQuery = $state(false);
|
let ranQuery = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
searchQuery = new URL(window.location.href).searchParams.get('q') ?? '';
|
searchQuery = new URL(window.location.href).searchParams.get("q") ?? "";
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
ranQuery = true;
|
ranQuery = true;
|
||||||
results = (await query('notes:search', { query: searchQuery })) as NoteDoc[];
|
results = (await query("notes:search", { query: searchQuery })) as NoteDoc[];
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{searchQuery ? `Search: ${searchQuery}` : 'Search'} — Notebook</title>
|
<title>{searchQuery ? `Search: ${searchQuery}` : "Search"} — Notebook</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1 class="font-serif text-4xl font-medium text-ink">Search</h1>
|
<h1 class="text-ink font-serif text-4xl font-medium">Search</h1>
|
||||||
<form
|
<form
|
||||||
class="mt-6 mb-8 border-b border-rule pb-8"
|
class="border-rule mt-6 mb-8 border-b pb-8"
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const q = searchQuery.trim();
|
const q = searchQuery.trim();
|
||||||
|
|
@ -36,7 +36,13 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label for="q" class="kicker mb-2 block">Query</label>
|
<label for="q" class="kicker mb-2 block">Query</label>
|
||||||
<input id="q" type="search" bind:value={searchQuery} placeholder="Search notes..." class="field" />
|
<input
|
||||||
|
id="q"
|
||||||
|
type="search"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Search notes..."
|
||||||
|
class="field"
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -52,8 +58,12 @@
|
||||||
targetId={note._id}
|
targetId={note._id}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-muted">No results found for “{searchQuery}”.</p>
|
<p class="text-muted text-sm">No results found for “{searchQuery}”.</p>
|
||||||
<a href="/notes" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Browse notes</a>
|
<a
|
||||||
|
href="/notes"
|
||||||
|
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
|
||||||
|
>Browse notes</a
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query } from '$lib/api';
|
import { query } from "$lib/api";
|
||||||
import { page } from '$app/state';
|
import { page } from "$app/state";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import FeedRow from '$lib/components/FeedRow.svelte';
|
import FeedRow from "$lib/components/FeedRow.svelte";
|
||||||
import { timeAgo } from '$lib/time';
|
import { timeAgo } from "$lib/time";
|
||||||
import type { NoteDoc, QuestionDoc, TopicDoc } from '$lib/types';
|
import type { NoteDoc, QuestionDoc, TopicDoc } from "$lib/types";
|
||||||
|
|
||||||
let topic: TopicDoc | null = $state(null);
|
let topic: TopicDoc | null = $state(null);
|
||||||
let notes: NoteDoc[] = $state([]);
|
let notes: NoteDoc[] = $state([]);
|
||||||
|
|
@ -13,12 +13,12 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const slug = page.params.slug;
|
const slug = page.params.slug;
|
||||||
const t = await query('topics:getBySlug', { slug });
|
const t = await query("topics:getBySlug", { slug });
|
||||||
topic = t as TopicDoc;
|
topic = t as TopicDoc;
|
||||||
if (topic) {
|
if (topic) {
|
||||||
const [n, q] = await Promise.all([
|
const [n, q] = await Promise.all([
|
||||||
query('notes:list', { topicId: topic._id }),
|
query("notes:list", { topicId: topic._id }),
|
||||||
query('questions:list', { topicId: topic._id })
|
query("questions:list", { topicId: topic._id }),
|
||||||
]);
|
]);
|
||||||
notes = n as NoteDoc[];
|
notes = n as NoteDoc[];
|
||||||
questions = q as QuestionDoc[];
|
questions = q as QuestionDoc[];
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{topic?.name ?? 'Topic'} — Notebook</title>
|
<title>{topic?.name ?? "Topic"} — Notebook</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -36,18 +36,20 @@
|
||||||
<p class="kicker py-16">Loading</p>
|
<p class="kicker py-16">Loading</p>
|
||||||
{:else if topic}
|
{:else if topic}
|
||||||
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Topic</p>
|
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Topic</p>
|
||||||
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">{topic.name}</h1>
|
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">{topic.name}</h1>
|
||||||
{#if topic.description}
|
{#if topic.description}
|
||||||
<p class="mt-2 text-[15px] text-muted">{topic.description}</p>
|
<p class="text-muted mt-2 text-[15px]">{topic.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if notes.length > 0}
|
{#if notes.length > 0}
|
||||||
<p class="kicker mt-10 border-t border-rule pt-8">Notes</p>
|
<p class="kicker border-rule mt-10 border-t pt-8">Notes</p>
|
||||||
{#each notes as note}
|
{#each notes as note}
|
||||||
<FeedRow
|
<FeedRow
|
||||||
href="/notes/{note._id}"
|
href="/notes/{note._id}"
|
||||||
title={note.title}
|
title={note.title}
|
||||||
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
meta="{note.authorName} · {timeAgo(
|
||||||
|
note.createdAt,
|
||||||
|
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
||||||
voteCount={note.voteCount}
|
voteCount={note.voteCount}
|
||||||
targetType="note"
|
targetType="note"
|
||||||
targetId={note._id}
|
targetId={note._id}
|
||||||
|
|
@ -56,12 +58,14 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if questions.length > 0}
|
{#if questions.length > 0}
|
||||||
<p class="kicker mt-10 border-t border-rule pt-8">Questions</p>
|
<p class="kicker border-rule mt-10 border-t pt-8">Questions</p>
|
||||||
{#each questions as question}
|
{#each questions as question}
|
||||||
<FeedRow
|
<FeedRow
|
||||||
href="/questions/{question._id}"
|
href="/questions/{question._id}"
|
||||||
title={question.title}
|
title={question.title}
|
||||||
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
meta="{question.authorName} · {timeAgo(
|
||||||
|
question.createdAt,
|
||||||
|
)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
||||||
voteCount={question.voteCount}
|
voteCount={question.voteCount}
|
||||||
targetType="question"
|
targetType="question"
|
||||||
targetId={question._id}
|
targetId={question._id}
|
||||||
|
|
@ -70,14 +74,20 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if notes.length === 0 && questions.length === 0}
|
{#if notes.length === 0 && questions.length === 0}
|
||||||
<p class="mt-10 text-sm text-muted">No content in this topic yet.</p>
|
<p class="text-muted mt-10 text-sm">No content in this topic yet.</p>
|
||||||
<div class="mt-4 flex gap-4">
|
<div class="mt-4 flex gap-4">
|
||||||
<a href="/post/note" class="text-sm text-secondary hover:text-secondary-dark">Post a note</a>
|
<a href="/post/note" class="text-secondary hover:text-secondary-dark text-sm"
|
||||||
<a href="/post/question" class="text-sm text-secondary hover:text-secondary-dark">Ask a question</a>
|
>Post a note</a
|
||||||
|
>
|
||||||
|
<a href="/post/question" class="text-secondary hover:text-secondary-dark text-sm"
|
||||||
|
>Ask a question</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="font-serif text-3xl text-ink">Topic not found</h1>
|
<h1 class="text-ink font-serif text-3xl">Topic not found</h1>
|
||||||
<a href="/" class="mt-2 inline-block text-sm text-secondary hover:text-secondary-dark">Home</a>
|
<a href="/" class="text-secondary hover:text-secondary-dark mt-2 inline-block text-sm"
|
||||||
|
>Home</a
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { query } from '$lib/api';
|
import { query } from "$lib/api";
|
||||||
import { page } from '$app/state';
|
import { page } from "$app/state";
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import FeedRow from '$lib/components/FeedRow.svelte';
|
import FeedRow from "$lib/components/FeedRow.svelte";
|
||||||
import { timeAgo } from '$lib/time';
|
import { timeAgo } from "$lib/time";
|
||||||
import type { NoteDoc, QuestionDoc, UnitDoc } from '$lib/types';
|
import type { NoteDoc, QuestionDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
let unit: UnitDoc | null = $state(null);
|
let unit: UnitDoc | null = $state(null);
|
||||||
let notes: NoteDoc[] = $state([]);
|
let notes: NoteDoc[] = $state([]);
|
||||||
|
|
@ -13,12 +13,12 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const code = page.params.code;
|
const code = page.params.code;
|
||||||
const u = await query('units:getByCode', { code });
|
const u = await query("units:getByCode", { code });
|
||||||
unit = u as UnitDoc;
|
unit = u as UnitDoc;
|
||||||
if (unit) {
|
if (unit) {
|
||||||
const [n, q] = await Promise.all([
|
const [n, q] = await Promise.all([
|
||||||
query('notes:list', { unitId: unit._id }),
|
query("notes:list", { unitId: unit._id }),
|
||||||
query('questions:list', { unitId: unit._id })
|
query("questions:list", { unitId: unit._id }),
|
||||||
]);
|
]);
|
||||||
notes = n as NoteDoc[];
|
notes = n as NoteDoc[];
|
||||||
questions = q as QuestionDoc[];
|
questions = q as QuestionDoc[];
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{unit?.code ?? 'Unit'} — Notebook</title>
|
<title>{unit?.code ?? "Unit"} — Notebook</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -36,20 +36,22 @@
|
||||||
<p class="kicker py-16">Loading</p>
|
<p class="kicker py-16">Loading</p>
|
||||||
{:else if unit}
|
{:else if unit}
|
||||||
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Unit</p>
|
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Unit</p>
|
||||||
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">{unit.code}</h1>
|
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">{unit.code}</h1>
|
||||||
<p class="mt-1 text-[15px] text-muted">{unit.name}</p>
|
<p class="text-muted mt-1 text-[15px]">{unit.name}</p>
|
||||||
{#if unit.description}
|
{#if unit.description}
|
||||||
<p class="mt-2 text-sm text-muted">{unit.description}</p>
|
<p class="text-muted mt-2 text-sm">{unit.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if notes.length > 0}
|
{#if notes.length > 0}
|
||||||
<p class="kicker mt-10 border-t border-rule pt-8">Notes</p>
|
<p class="kicker border-rule mt-10 border-t pt-8">Notes</p>
|
||||||
{#each notes as note}
|
{#each notes as note}
|
||||||
<FeedRow
|
<FeedRow
|
||||||
href="/notes/{note._id}"
|
href="/notes/{note._id}"
|
||||||
title={note.title}
|
title={note.title}
|
||||||
unitCode={unit.code}
|
unitCode={unit.code}
|
||||||
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
meta="{note.authorName} · {timeAgo(
|
||||||
|
note.createdAt,
|
||||||
|
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
||||||
voteCount={note.voteCount}
|
voteCount={note.voteCount}
|
||||||
targetType="note"
|
targetType="note"
|
||||||
targetId={note._id}
|
targetId={note._id}
|
||||||
|
|
@ -58,13 +60,15 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if questions.length > 0}
|
{#if questions.length > 0}
|
||||||
<p class="kicker mt-10 border-t border-rule pt-8">Questions</p>
|
<p class="kicker border-rule mt-10 border-t pt-8">Questions</p>
|
||||||
{#each questions as question}
|
{#each questions as question}
|
||||||
<FeedRow
|
<FeedRow
|
||||||
href="/questions/{question._id}"
|
href="/questions/{question._id}"
|
||||||
title={question.title}
|
title={question.title}
|
||||||
unitCode={unit.code}
|
unitCode={unit.code}
|
||||||
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
meta="{question.authorName} · {timeAgo(
|
||||||
|
question.createdAt,
|
||||||
|
)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
||||||
voteCount={question.voteCount}
|
voteCount={question.voteCount}
|
||||||
targetType="question"
|
targetType="question"
|
||||||
targetId={question._id}
|
targetId={question._id}
|
||||||
|
|
@ -73,14 +77,20 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if notes.length === 0 && questions.length === 0}
|
{#if notes.length === 0 && questions.length === 0}
|
||||||
<p class="mt-10 text-sm text-muted">No content for this unit yet.</p>
|
<p class="text-muted mt-10 text-sm">No content for this unit yet.</p>
|
||||||
<div class="mt-4 flex gap-4">
|
<div class="mt-4 flex gap-4">
|
||||||
<a href="/post/note" class="text-sm text-secondary hover:text-secondary-dark">Post a note</a>
|
<a href="/post/note" class="text-secondary hover:text-secondary-dark text-sm"
|
||||||
<a href="/post/question" class="text-sm text-secondary hover:text-secondary-dark">Ask a question</a>
|
>Post a note</a
|
||||||
|
>
|
||||||
|
<a href="/post/question" class="text-secondary hover:text-secondary-dark text-sm"
|
||||||
|
>Ask a question</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="font-serif text-3xl text-ink">Unit not found</h1>
|
<h1 class="text-ink font-serif text-3xl">Unit not found</h1>
|
||||||
<a href="/" class="mt-2 inline-block text-sm text-secondary hover:text-secondary-dark">Home</a>
|
<a href="/" class="text-secondary hover:text-secondary-dark mt-2 inline-block text-sm"
|
||||||
|
>Home</a
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from "vitest/config";
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from "@vitest/browser-playwright";
|
||||||
import adapter from '@sveltejs/adapter-node';
|
import adapter from "@sveltejs/adapter-node";
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
@ -10,39 +10,40 @@ export default defineConfig({
|
||||||
sveltekit({
|
sveltekit({
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
runes: ({ filename }) =>
|
||||||
|
filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// adapter-node is used because the app persists data to a local SQLite file on the server.
|
// adapter-node is used because the app persists data to a local SQLite file on the server.
|
||||||
adapter: adapter()
|
adapter: adapter(),
|
||||||
})
|
}),
|
||||||
],
|
],
|
||||||
test: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
extends: './vite.config.ts',
|
extends: "./vite.config.ts",
|
||||||
test: {
|
test: {
|
||||||
name: 'client',
|
name: "client",
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
provider: playwright(),
|
provider: playwright(),
|
||||||
instances: [{ browser: 'chromium', headless: true }]
|
instances: [{ browser: "chromium", headless: true }],
|
||||||
|
},
|
||||||
|
include: ["src/**/*.svelte.{test,spec}.{js,ts}"],
|
||||||
|
exclude: ["src/lib/server/**"],
|
||||||
},
|
},
|
||||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
|
||||||
exclude: ['src/lib/server/**']
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
extends: './vite.config.ts',
|
extends: "./vite.config.ts",
|
||||||
test: {
|
test: {
|
||||||
name: 'server',
|
name: "server",
|
||||||
environment: 'node',
|
environment: "node",
|
||||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
include: ["src/**/*.{test,spec}.{js,ts}"],
|
||||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue