mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
first commit
This commit is contained in:
commit
2bb77ea3ac
44 changed files with 5682 additions and 0 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.svelte-kit
|
||||||
|
.git
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
README.md
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
/data
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
||||||
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- Build stage ----
|
||||||
|
FROM node:24-alpine AS build
|
||||||
|
|
||||||
|
# Skip Playwright browser download (only needed for tests, not for building).
|
||||||
|
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Runtime stage ----
|
||||||
|
FROM node:24-alpine AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
PORT=3000 \
|
||||||
|
DATABASE_PATH=data/dsec.db
|
||||||
|
|
||||||
|
COPY --from=build /app/build ./build
|
||||||
|
|
||||||
|
RUN mkdir -p data && chown -R node:node /app
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
CMD ["node", "build/index.js"]
|
||||||
42
README.md
Normal file
42
README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
npx sv@0.17.0 create --template minimal --types ts --add vitest="usages:unit,component" tailwindcss="plugins:none" --install npm .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_PATH: data/dsec.db
|
||||||
|
volumes:
|
||||||
|
- dsec-data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dsec-data:
|
||||||
3143
package-lock.json
generated
Normal file
3143
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "dsec-notebook",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"test": "npm run test:unit -- --run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
|
"@sveltejs/kit": "^2.63.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/node": "^26.3.0",
|
||||||
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.8",
|
||||||
|
"vitest-browser-svelte": "^2.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/app.d.ts
vendored
Normal file
9
src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface PageData {}
|
||||||
|
interface PageState {}
|
||||||
|
interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app.html
Normal file
18
src/app.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,500;0,600;0,700;1,500&family=Inter:ital,opsz,wght@0,14..32,400;0,14..32,500;0,14..32,600;1,14..32,400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
src/lib/api.ts
Normal file
21
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
async function call(name: string, args: Record<string, any> = {}): Promise<any> {
|
||||||
|
const res = await fetch('/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fn: name, args })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
throw new Error(data.error ?? 'Request failed');
|
||||||
|
}
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function query(name: string, args: Record<string, any> = {}): Promise<any> {
|
||||||
|
return call(name, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mutation(name: string, args: Record<string, any> = {}): Promise<any> {
|
||||||
|
return call(name, args);
|
||||||
|
}
|
||||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
34
src/lib/components/FeedRow.svelte
Normal file
34
src/lib/components/FeedRow.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import VoteStack from './VoteStack.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
href,
|
||||||
|
title,
|
||||||
|
unitCode,
|
||||||
|
meta,
|
||||||
|
voteCount = 0,
|
||||||
|
targetType,
|
||||||
|
targetId
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
unitCode?: string;
|
||||||
|
meta: string;
|
||||||
|
voteCount?: number;
|
||||||
|
targetType: 'note' | 'question';
|
||||||
|
targetId: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="flex gap-4 border-b border-rule py-5">
|
||||||
|
<VoteStack count={voteCount} {targetType} {targetId} />
|
||||||
|
<a {href} class="min-w-0 flex-1 group">
|
||||||
|
{#if unitCode}
|
||||||
|
<p class="kicker">{unitCode}</p>
|
||||||
|
{/if}
|
||||||
|
<h3 class="mt-1 font-sans text-[15px] font-semibold leading-snug text-ink group-hover:text-primary">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p class="kicker mt-1.5">{meta}</p>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
76
src/lib/components/Navbar.svelte
Normal file
76
src/lib/components/Navbar.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { isAuthenticated, initAuth, logout } from '$lib/stores/auth';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
let mobileMenuOpen = $state(false);
|
||||||
|
let auth = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initAuth();
|
||||||
|
const unsub = isAuthenticated.subscribe((v) => (auth = v));
|
||||||
|
return () => unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
const path = $derived(page.url.pathname);
|
||||||
|
const onNotes = $derived(path === '/notes' || path.startsWith('/notes/'));
|
||||||
|
const onQuestions = $derived(path === '/questions' || path.startsWith('/questions/'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="border-b border-rule bg-white">
|
||||||
|
<div class="mx-auto flex h-16 max-w-4xl items-center justify-between px-4 sm:px-6">
|
||||||
|
<a href="/" class="font-serif text-[1.65rem] leading-none text-ink">Notebook</a>
|
||||||
|
|
||||||
|
<nav class="hidden items-center gap-8 md:flex">
|
||||||
|
<a href="/notes" class="nav-link {onNotes ? 'nav-link-active' : ''}">Notes</a>
|
||||||
|
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}">Questions</a>
|
||||||
|
{#if auth}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-link"
|
||||||
|
onclick={() => {
|
||||||
|
logout();
|
||||||
|
goto('/');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a href="/auth/login" class="nav-link">Sign in</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="p-1 text-muted hover:text-ink md:hidden"
|
||||||
|
aria-label="Open menu"
|
||||||
|
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="square" d="M4 7h16M4 12h16M4 17h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mobileMenuOpen}
|
||||||
|
<nav class="space-y-3 border-t border-rule px-4 py-4 md:hidden">
|
||||||
|
<a href="/notes" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Notes</a>
|
||||||
|
<a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Questions</a>
|
||||||
|
{#if auth}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-link block"
|
||||||
|
onclick={() => {
|
||||||
|
logout();
|
||||||
|
goto('/');
|
||||||
|
mobileMenuOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a href="/auth/login" class="nav-link block" onclick={() => (mobileMenuOpen = false)}>Sign in</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
73
src/lib/components/VoteStack.svelte
Normal file
73
src/lib/components/VoteStack.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { mutation } from '$lib/api';
|
||||||
|
import { getToken } from '$lib/stores/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let {
|
||||||
|
count = 0,
|
||||||
|
targetType,
|
||||||
|
targetId
|
||||||
|
}: {
|
||||||
|
count?: number;
|
||||||
|
targetType: 'note' | 'question';
|
||||||
|
targetId: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let localCount = $state<number | null>(null);
|
||||||
|
let userVote = $state(0);
|
||||||
|
let busy = $state(false);
|
||||||
|
const voteCount = $derived(localCount ?? count);
|
||||||
|
|
||||||
|
async function vote(value: 1 | -1, e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
goto('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await mutation('votes:cast', { token, targetType, targetId, value });
|
||||||
|
localCount = result.voteCount;
|
||||||
|
userVote = result.userVote;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex w-8 shrink-0 flex-col gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center border border-rule text-muted transition-colors hover:border-secondary hover:text-secondary {userVote === 1
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: ''}"
|
||||||
|
onclick={(e) => vote(1, e)}
|
||||||
|
aria-label="Upvote"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path d="M6 15l6-6 6 6" stroke-linecap="square" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center border border-rule font-sans text-xs text-ink">
|
||||||
|
{voteCount}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center border border-rule text-muted transition-colors hover:border-secondary hover:text-secondary {userVote === -1
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: ''}"
|
||||||
|
onclick={(e) => vote(-1, e)}
|
||||||
|
aria-label="Downvote"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path d="M6 9l6 6 6-6" stroke-linecap="square" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
392
src/lib/server/api.ts
Normal file
392
src/lib/server/api.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
import { getDb } from './db';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const DEAKIN_DOMAIN = 'deakin.edu.au';
|
||||||
|
|
||||||
|
type Db = ReturnType<typeof getDb>;
|
||||||
|
|
||||||
|
function newId(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(db: Db, token: string): Record<string, any> {
|
||||||
|
const user = db
|
||||||
|
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?')
|
||||||
|
.get(token) as Record<string, any> | undefined;
|
||||||
|
if (!user) throw new Error('Not authenticated');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapQuestion(row: Record<string, any>): Record<string, any> {
|
||||||
|
return { ...row, solved: !!row.solved };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- users ----
|
||||||
|
|
||||||
|
function usersRegister(db: Db, args: { email: string; name: string }) {
|
||||||
|
const email = args.email.toLowerCase();
|
||||||
|
const emailDomain = email.split('@')[1]?.toLowerCase();
|
||||||
|
if (emailDomain !== DEAKIN_DOMAIN) {
|
||||||
|
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?')
|
||||||
|
.get(email) as Record<string, any> | undefined;
|
||||||
|
|
||||||
|
const token = generateToken();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('UPDATE users SET sessionToken = ? WHERE id = ?').run(token, existing._id);
|
||||||
|
return { userId: existing._id, token, name: existing.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = newId();
|
||||||
|
db.prepare('INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)').run(
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
args.name,
|
||||||
|
token,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
return { userId: id, token, name: args.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function usersGetByToken(db: Db, args: { token: string }) {
|
||||||
|
return (
|
||||||
|
db
|
||||||
|
.prepare('SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?')
|
||||||
|
.get(args.token) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- topics ----
|
||||||
|
|
||||||
|
function topicsGetBySlug(db: Db, args: { slug: string }) {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE slug = ?')
|
||||||
|
.get(args.slug) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicsGetAll(db: Db) {
|
||||||
|
return db.prepare('SELECT id AS _id, name, slug, description FROM topics ORDER BY name ASC').all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- units ----
|
||||||
|
|
||||||
|
function unitsGetByCode(db: Db, args: { code: string }) {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT id AS _id, code, name, description FROM units WHERE code = ?')
|
||||||
|
.get(args.code.toUpperCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitsGetAll(db: Db) {
|
||||||
|
return db.prepare('SELECT id AS _id, code, name, description FROM units ORDER BY code ASC').all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitsCreateCustom(db: Db, args: { code: string; name: string }) {
|
||||||
|
const code = args.code.toUpperCase();
|
||||||
|
const existing = db.prepare('SELECT id AS _id FROM units WHERE code = ?').get(code) as
|
||||||
|
| { _id: string }
|
||||||
|
| undefined;
|
||||||
|
if (existing) return existing._id;
|
||||||
|
|
||||||
|
const id = newId();
|
||||||
|
db.prepare('INSERT INTO units (id, code, name) VALUES (?, ?, ?)').run(id, code, args.name);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- notes ----
|
||||||
|
|
||||||
|
const NOTE_COLUMNS =
|
||||||
|
'id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount';
|
||||||
|
|
||||||
|
function notesCreate(
|
||||||
|
db: Db,
|
||||||
|
args: { token: string; title: string; content: string; topicId: string; unitId: string }
|
||||||
|
) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const id = newId();
|
||||||
|
const now = Date.now();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)`
|
||||||
|
).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notesList(db: Db, args: { topicId?: string; unitId?: string; limit?: number }) {
|
||||||
|
const limit = args.limit ?? 50;
|
||||||
|
if (args.topicId) {
|
||||||
|
return db
|
||||||
|
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`)
|
||||||
|
.all(args.topicId, limit);
|
||||||
|
}
|
||||||
|
if (args.unitId) {
|
||||||
|
return db
|
||||||
|
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`)
|
||||||
|
.all(args.unitId, limit);
|
||||||
|
}
|
||||||
|
return db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes ORDER BY createdAt DESC LIMIT ?`).all(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notesSearch(db: Db, args: { query: string; limit?: number }) {
|
||||||
|
const all = db
|
||||||
|
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes ORDER BY createdAt DESC LIMIT ?`)
|
||||||
|
.all(args.limit ?? 200) as Record<string, any>[];
|
||||||
|
const q = args.query.toLowerCase();
|
||||||
|
return all.filter(
|
||||||
|
(n) => String(n.title).toLowerCase().includes(q) || String(n.content).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notesGetById(db: Db, args: { id: string }) {
|
||||||
|
return db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE id = ?`).get(args.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notesRemove(db: Db, args: { token: string; id: string }) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const note = db.prepare('SELECT id, authorId FROM notes WHERE id = ?').get(args.id) as
|
||||||
|
| { id: string; authorId: string }
|
||||||
|
| undefined;
|
||||||
|
if (!note || note.authorId !== user._id) throw new Error('Not authorized');
|
||||||
|
db.prepare('DELETE FROM notes WHERE id = ?').run(args.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- questions ----
|
||||||
|
|
||||||
|
const QUESTION_COLUMNS =
|
||||||
|
'id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved';
|
||||||
|
|
||||||
|
function questionsCreate(
|
||||||
|
db: Db,
|
||||||
|
args: { token: string; title: string; content: string; topicId: string; unitId: string }
|
||||||
|
) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const id = newId();
|
||||||
|
const now = Date.now();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0)`
|
||||||
|
).run(id, args.title, args.content, args.topicId, args.unitId, user._id, user.name, now, now);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionsList(db: Db, args: { topicId?: string; unitId?: string; limit?: number }) {
|
||||||
|
const limit = args.limit ?? 50;
|
||||||
|
const rows = (
|
||||||
|
args.topicId
|
||||||
|
? db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`).all(args.topicId, limit)
|
||||||
|
: args.unitId
|
||||||
|
? db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`).all(args.unitId, limit)
|
||||||
|
: db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions ORDER BY createdAt DESC LIMIT ?`).all(limit)
|
||||||
|
) as Record<string, any>[];
|
||||||
|
return rows.map(mapQuestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionsGetById(db: Db, args: { id: string }) {
|
||||||
|
const row = db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`).get(args.id) as
|
||||||
|
| Record<string, any>
|
||||||
|
| undefined;
|
||||||
|
return row ? mapQuestion(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionsMarkSolved(db: Db, args: { token: string; id: string }) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const question = db.prepare('SELECT id, authorId FROM questions WHERE id = ?').get(args.id) as
|
||||||
|
| { id: string; authorId: string }
|
||||||
|
| undefined;
|
||||||
|
if (!question || question.authorId !== user._id) throw new Error('Not authorized');
|
||||||
|
db.prepare('UPDATE questions SET solved = 1 WHERE id = ?').run(args.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionsRemove(db: Db, args: { token: string; id: string }) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const question = db.prepare('SELECT id, authorId FROM questions WHERE id = ?').get(args.id) as
|
||||||
|
| { id: string; authorId: string }
|
||||||
|
| undefined;
|
||||||
|
if (!question || question.authorId !== user._id) throw new Error('Not authorized');
|
||||||
|
db.prepare('DELETE FROM questions WHERE id = ?').run(args.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- comments ----
|
||||||
|
|
||||||
|
const COMMENT_COLUMNS =
|
||||||
|
'id AS _id, content, authorId, authorName, parentId, questionId, parentCommentId, createdAt';
|
||||||
|
|
||||||
|
function commentsCreateOnNote(
|
||||||
|
db: Db,
|
||||||
|
args: { token: string; content: string; parentId: string; parentCommentId?: string }
|
||||||
|
) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const id = newId();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(id, args.content, user._id, user.name, args.parentId, args.parentCommentId ?? null, Date.now());
|
||||||
|
db.prepare('UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?').run(args.parentId);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentsCreateOnQuestion(
|
||||||
|
db: Db,
|
||||||
|
args: { token: string; content: string; questionId: string; parentCommentId?: string }
|
||||||
|
) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const id = newId();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(id, args.content, user._id, user.name, args.questionId, args.parentCommentId ?? null, Date.now());
|
||||||
|
db.prepare('UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?').run(args.questionId);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentsListByNote(db: Db, args: { noteId: string }) {
|
||||||
|
return db
|
||||||
|
.prepare(`SELECT ${COMMENT_COLUMNS} FROM comments WHERE parentId = ? ORDER BY createdAt ASC`)
|
||||||
|
.all(args.noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentsListByQuestion(db: Db, args: { questionId: string }) {
|
||||||
|
return db
|
||||||
|
.prepare(`SELECT ${COMMENT_COLUMNS} FROM comments WHERE questionId = ? ORDER BY createdAt ASC`)
|
||||||
|
.all(args.questionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentsRemove(db: Db, args: { token: string; id: string }) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const comment = db.prepare('SELECT id, authorId FROM comments WHERE id = ?').get(args.id) as
|
||||||
|
| { id: string; authorId: string }
|
||||||
|
| undefined;
|
||||||
|
if (!comment || comment.authorId !== user._id) throw new Error('Not authorized');
|
||||||
|
db.prepare('DELETE FROM comments WHERE id = ?').run(args.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- votes ----
|
||||||
|
|
||||||
|
function votesCast(
|
||||||
|
db: Db,
|
||||||
|
args: { token: string; targetType: 'note' | 'question'; targetId: string; value: 1 | -1 }
|
||||||
|
) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const isNote = args.targetType === 'note';
|
||||||
|
const table = isNote ? 'notes' : 'questions';
|
||||||
|
const doc = db.prepare(`SELECT id, voteCount FROM ${table} WHERE id = ?`).get(args.targetId) as
|
||||||
|
| { id: string; voteCount: number }
|
||||||
|
| undefined;
|
||||||
|
if (!doc) throw new Error('Not found');
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.prepare('SELECT id, value FROM votes WHERE userId = ? AND targetType = ? AND targetId = ?')
|
||||||
|
.get(user._id, args.targetType, args.targetId) as { id: string; value: number } | undefined;
|
||||||
|
|
||||||
|
let voteCount = doc.voteCount;
|
||||||
|
let userVote: number = args.value;
|
||||||
|
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
if (existing) {
|
||||||
|
if (existing.value === args.value) {
|
||||||
|
db.prepare('DELETE FROM votes WHERE id = ?').run(existing.id);
|
||||||
|
voteCount -= args.value;
|
||||||
|
userVote = 0;
|
||||||
|
} else {
|
||||||
|
db.prepare('UPDATE votes SET value = ? WHERE id = ?').run(args.value, existing.id);
|
||||||
|
voteCount = voteCount - existing.value + args.value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
db.prepare('INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, ?, ?, ?)').run(
|
||||||
|
newId(),
|
||||||
|
user._id,
|
||||||
|
args.targetType,
|
||||||
|
args.targetId,
|
||||||
|
args.value
|
||||||
|
);
|
||||||
|
voteCount += args.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`UPDATE ${table} SET voteCount = ? WHERE id = ?`).run(voteCount, args.targetId);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { voteCount, userVote };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- details ----
|
||||||
|
|
||||||
|
function getNoteWithDetails(db: Db, args: { id: string }) {
|
||||||
|
const note = db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE id = ?`).get(args.id) as
|
||||||
|
| Record<string, any>
|
||||||
|
| undefined;
|
||||||
|
if (!note) return null;
|
||||||
|
|
||||||
|
const topic = db.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE id = ?').get(note.topicId);
|
||||||
|
const unit = db.prepare('SELECT id AS _id, code, name, description FROM units WHERE id = ?').get(note.unitId);
|
||||||
|
const comments = commentsListByNote(db, { noteId: note._id });
|
||||||
|
|
||||||
|
return { ...note, topic, unit, comments };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionWithDetails(db: Db, args: { id: string }) {
|
||||||
|
const question = db.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`).get(args.id) as
|
||||||
|
| Record<string, any>
|
||||||
|
| undefined;
|
||||||
|
if (!question) return null;
|
||||||
|
|
||||||
|
const topic = db.prepare('SELECT id AS _id, name, slug, description FROM topics WHERE id = ?').get(question.topicId);
|
||||||
|
const unit = db.prepare('SELECT id AS _id, code, name, description FROM units WHERE id = ?').get(question.unitId);
|
||||||
|
const answers = commentsListByQuestion(db, { questionId: question._id });
|
||||||
|
|
||||||
|
return { ...mapQuestion(question), topic, unit, answers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dispatcher ----
|
||||||
|
|
||||||
|
type Handler = (db: Db, args: any) => any;
|
||||||
|
|
||||||
|
const handlers: Record<string, Handler> = {
|
||||||
|
'users:register': usersRegister,
|
||||||
|
'users:getByToken': usersGetByToken,
|
||||||
|
'topics:getBySlug': topicsGetBySlug,
|
||||||
|
'topics:getAll': topicsGetAll,
|
||||||
|
'units:getByCode': unitsGetByCode,
|
||||||
|
'units:getAll': unitsGetAll,
|
||||||
|
'units:createCustom': unitsCreateCustom,
|
||||||
|
'notes:create': notesCreate,
|
||||||
|
'notes:list': notesList,
|
||||||
|
'notes:search': notesSearch,
|
||||||
|
'notes:getById': notesGetById,
|
||||||
|
'notes:remove': notesRemove,
|
||||||
|
'questions:create': questionsCreate,
|
||||||
|
'questions:list': questionsList,
|
||||||
|
'questions:getById': questionsGetById,
|
||||||
|
'questions:markSolved': questionsMarkSolved,
|
||||||
|
'questions:remove': questionsRemove,
|
||||||
|
'comments:createOnNote': commentsCreateOnNote,
|
||||||
|
'comments:createOnQuestion': commentsCreateOnQuestion,
|
||||||
|
'comments:listByNote': commentsListByNote,
|
||||||
|
'comments:listByQuestion': commentsListByQuestion,
|
||||||
|
'comments:remove': commentsRemove,
|
||||||
|
'votes:cast': votesCast,
|
||||||
|
'details:getNoteWithDetails': getNoteWithDetails,
|
||||||
|
'details:getQuestionWithDetails': getQuestionWithDetails,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function call(fn: string, args: Record<string, any> = {}): any {
|
||||||
|
const handler = handlers[fn];
|
||||||
|
if (!handler) throw new Error(`Unknown function: ${fn}`);
|
||||||
|
return handler(getDb(), args ?? {});
|
||||||
|
}
|
||||||
173
src/lib/server/db.ts
Normal file
173
src/lib/server/db.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const DB_PATH = resolve(process.env.DATABASE_PATH ?? 'data/dsec.db');
|
||||||
|
|
||||||
|
let db: DatabaseSync | null = null;
|
||||||
|
|
||||||
|
function createSchema(database: DatabaseSync) {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sessionToken TEXT,
|
||||||
|
createdAt INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS units (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
topicId TEXT NOT NULL,
|
||||||
|
unitId TEXT NOT NULL,
|
||||||
|
authorId TEXT NOT NULL,
|
||||||
|
authorName TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
voteCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
commentCount INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
topicId TEXT NOT NULL,
|
||||||
|
unitId TEXT NOT NULL,
|
||||||
|
authorId TEXT NOT NULL,
|
||||||
|
authorName TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
voteCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
answerCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
solved INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
authorId TEXT NOT NULL,
|
||||||
|
authorName TEXT NOT NULL,
|
||||||
|
parentId TEXT,
|
||||||
|
questionId TEXT,
|
||||||
|
parentCommentId TEXT,
|
||||||
|
createdAt INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS votes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
targetType TEXT NOT NULL,
|
||||||
|
targetId TEXT NOT NULL,
|
||||||
|
value INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topicId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_topic ON questions(topicId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_unit ON questions(unitId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_created ON questions(createdAt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comments_note ON comments(parentId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comments_question ON comments(questionId);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_votes_user_target ON votes(userId, targetType, targetId);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEED_TOPICS = [
|
||||||
|
{ name: 'Algorithms', slug: 'algorithms', description: 'Algorithm design, analysis, and common patterns' },
|
||||||
|
{ name: 'Data Structures', slug: 'data-structures', description: 'Arrays, linked lists, trees, graphs, and more' },
|
||||||
|
{ name: 'Networking', slug: 'networking', description: 'Computer networks, protocols, and architectures' },
|
||||||
|
{ name: 'Cybersecurity', slug: 'cybersecurity', description: 'Security principles, threats, and defenses' },
|
||||||
|
{ name: 'Databases', slug: 'databases', description: 'Relational and NoSQL databases, SQL, and design' },
|
||||||
|
{ name: 'Web Development', slug: 'web-development', description: 'HTML, CSS, JavaScript, and frameworks' },
|
||||||
|
{ name: 'Operating Systems', slug: 'operating-systems', description: 'OS concepts, processes, memory, and file systems' },
|
||||||
|
{ name: 'Software Engineering', slug: 'software-engineering', description: 'Design patterns, testing, and methodologies' },
|
||||||
|
{ name: 'Programming Languages', slug: 'programming-languages', description: 'Language concepts, paradigms, and syntax' },
|
||||||
|
{ name: 'Mathematics', slug: 'mathematics', description: 'Discrete math, linear algebra, statistics for CS' },
|
||||||
|
{ name: 'Machine Learning', slug: 'machine-learning', description: 'ML concepts, models, and techniques' },
|
||||||
|
{ name: 'Cloud Computing', slug: 'cloud-computing', description: 'Cloud platforms, services, and architecture' },
|
||||||
|
{ name: 'Mobile Development', slug: 'mobile-development', description: 'iOS, Android, and cross-platform development' },
|
||||||
|
{ name: 'DevOps', slug: 'devops', description: 'CI/CD, containers, and infrastructure' },
|
||||||
|
{ name: 'Computer Architecture', slug: 'computer-architecture', description: 'CPU, memory, and hardware design' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEED_UNITS = [
|
||||||
|
{ code: 'SIT102', name: 'Introduction to Programming' },
|
||||||
|
{ code: 'SIT111', name: 'Computer Systems' },
|
||||||
|
{ code: 'SIT192', name: 'Discrete Mathematics' },
|
||||||
|
{ code: 'SIT202', name: 'Computer Networks and Communication' },
|
||||||
|
{ code: 'SIT210', name: 'Embedded Systems Development' },
|
||||||
|
{ code: 'SIT221', name: 'Data Structures and Algorithms' },
|
||||||
|
{ code: 'SIT232', name: 'Object-Oriented Development' },
|
||||||
|
{ code: 'SIT281', name: 'Cryptography' },
|
||||||
|
{ code: 'SIT282', name: 'Computer Forensics' },
|
||||||
|
{ code: 'SIT283', name: 'Ethical Hacking' },
|
||||||
|
{ code: 'SIT284', name: 'Cyber Security Management' },
|
||||||
|
{ code: 'SIT313', name: 'Full Stack Web Development' },
|
||||||
|
{ code: 'SIT315', name: 'Programming Paradigms' },
|
||||||
|
{ code: 'SIT323', name: 'Cloud Native Application Development' },
|
||||||
|
{ code: 'SIT331', name: 'IT Security' },
|
||||||
|
{ code: 'SIT374', name: 'Team Project (A) - Project Management' },
|
||||||
|
{ code: 'SIT378', name: 'Team Project (B) - Execution' },
|
||||||
|
{ code: 'SIT379', name: 'Ethical Hacking' },
|
||||||
|
{ code: 'SIT384', name: 'Cyber Security Analytics' },
|
||||||
|
{ code: 'SIT393', name: 'Computing Internship' },
|
||||||
|
{ code: 'MIS771', name: 'Business Intelligence and Data Warehousing' },
|
||||||
|
{ code: 'MIS772', name: 'Predictive Analytics' },
|
||||||
|
{ code: 'MIS782', name: 'Information Security Governance' },
|
||||||
|
{ code: 'MIS784', name: 'Cyber Security Management and Practices' },
|
||||||
|
{ code: 'MIS785', name: 'IT Strategy and Governance' },
|
||||||
|
{ code: 'MIS798', name: 'Business Process Management' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function seed(database: DatabaseSync) {
|
||||||
|
const existing = database.prepare('SELECT COUNT(*) AS c FROM topics').get() as { c: number };
|
||||||
|
if (existing.c > 0) return;
|
||||||
|
|
||||||
|
const insertTopic = database.prepare(
|
||||||
|
'INSERT INTO topics (id, name, slug, description) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
const insertUnit = database.prepare('INSERT INTO units (id, code, name) VALUES (?, ?, ?)');
|
||||||
|
|
||||||
|
database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const topic of SEED_TOPICS) {
|
||||||
|
insertTopic.run(randomUUID(), topic.name, topic.slug, topic.description ?? null);
|
||||||
|
}
|
||||||
|
for (const unit of SEED_UNITS) {
|
||||||
|
insertUnit.run(randomUUID(), unit.code, unit.name);
|
||||||
|
}
|
||||||
|
database.exec('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
database.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(): DatabaseSync {
|
||||||
|
if (!db) {
|
||||||
|
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||||
|
db = new DatabaseSync(DB_PATH);
|
||||||
|
createSchema(db);
|
||||||
|
seed(db);
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
7
src/lib/stores/auth.spec.ts
Normal file
7
src/lib/stores/auth.spec.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('auth store', () => {
|
||||||
|
it('should be importable', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/lib/stores/auth.ts
Normal file
50
src/lib/stores/auth.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { query, mutation } from '$lib/api';
|
||||||
|
import type { UserDoc } from '$lib/types';
|
||||||
|
|
||||||
|
export const currentUser = writable<UserDoc | null>(null);
|
||||||
|
export const isAuthenticated = writable(false);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'dsec_session';
|
||||||
|
|
||||||
|
export async function initAuth() {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const { token } = JSON.parse(stored);
|
||||||
|
const user = await query('users:getByToken', { token });
|
||||||
|
if (user) {
|
||||||
|
currentUser.set(user as UserDoc);
|
||||||
|
isAuthenticated.set(true);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, name: string) {
|
||||||
|
const result = await mutation('users:register', { email, name });
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token }));
|
||||||
|
currentUser.set({ _id: result.userId, email: email.toLowerCase(), name, sessionToken: result.token, _creationTime: Date.now() } as UserDoc);
|
||||||
|
isAuthenticated.set(true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!stored) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored).token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
currentUser.set(null);
|
||||||
|
isAuthenticated.set(false);
|
||||||
|
}
|
||||||
13
src/lib/time.ts
Normal file
13
src/lib/time.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export function timeAgo(ts: number): string {
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'JUST NOW';
|
||||||
|
if (mins < 60) return `${mins}M AGO`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}H AGO`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) return `${days}D AGO`;
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 9) return `${weeks}W AGO`;
|
||||||
|
return `${Math.floor(days / 365) || 1}Y AGO`;
|
||||||
|
}
|
||||||
61
src/lib/types.ts
Normal file
61
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
export type Id<T extends string> = string & { __table: T };
|
||||||
|
|
||||||
|
export type Doc<T extends string> = {
|
||||||
|
_id: Id<T>;
|
||||||
|
_creationTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserDoc = Doc<"users"> & {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
sessionToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopicDoc = Doc<"topics"> & {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnitDoc = Doc<"units"> & {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteDoc = Doc<"notes"> & {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
topicId: Id<"topics">;
|
||||||
|
unitId: Id<"units">;
|
||||||
|
authorId: Id<"users">;
|
||||||
|
authorName: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
voteCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuestionDoc = Doc<"questions"> & {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
topicId: Id<"topics">;
|
||||||
|
unitId: Id<"units">;
|
||||||
|
authorId: Id<"users">;
|
||||||
|
authorName: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
voteCount: number;
|
||||||
|
answerCount: number;
|
||||||
|
solved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommentDoc = Doc<"comments"> & {
|
||||||
|
content: string;
|
||||||
|
authorId: Id<"users">;
|
||||||
|
authorName: string;
|
||||||
|
parentId?: Id<"notes">;
|
||||||
|
questionId?: Id<"questions">;
|
||||||
|
parentCommentId?: Id<"comments">;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
36
src/routes/+layout.svelte
Normal file
36
src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Navbar from '$lib/components/Navbar.svelte';
|
||||||
|
import { initAuth } from '$lib/stores/auth';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import './layout.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initAuth();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen flex-col bg-white">
|
||||||
|
<Navbar />
|
||||||
|
<main class="flex-1">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
<footer class="mt-16 border-t border-rule">
|
||||||
|
<div class="mx-auto flex max-w-4xl flex-col gap-2 px-4 py-8 sm:flex-row sm:items-end sm:justify-between sm:px-6">
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Notebook — written by students, for students.</p>
|
||||||
|
<p class="mt-2 text-xs text-faint">
|
||||||
|
DSEC Notebook is a community resource for Deakin University students. Not affiliated with Deakin University.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/search" class="kicker hover:text-primary">Search</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
52
src/routes/+page.svelte
Normal file
52
src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query } from '$lib/api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { UnitDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let units: UnitDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
units = (await query('units:getAll')) as UnitDoc[];
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Notebook — written by students</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<section class="pb-16 pt-6">
|
||||||
|
<p class="kicker">Written by students</p>
|
||||||
|
<h1 class="mt-4 max-w-xl font-serif text-4xl font-medium leading-[1.15] text-ink sm:text-5xl">
|
||||||
|
One notebook for every unit, every topic.
|
||||||
|
</h1>
|
||||||
|
<p class="mt-5 max-w-lg text-[15px] leading-relaxed text-muted">
|
||||||
|
Post notes in markdown, ask questions, and browse by unit. A shared notebook for Deakin students studying IT, computer science, and cybersecurity.
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
|
<a href="/notes" class="btn-primary">Browse notes</a>
|
||||||
|
<a href="/questions" class="btn-secondary">Browse questions</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border-t border-rule pt-10">
|
||||||
|
<p class="kicker mb-6">Units</p>
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker">Loading</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2">
|
||||||
|
{#each units as unit}
|
||||||
|
<a
|
||||||
|
href="/units/{unit.code}"
|
||||||
|
class="group flex items-baseline justify-between gap-4 border-b border-rule py-3"
|
||||||
|
>
|
||||||
|
<span class="font-serif text-lg text-ink group-hover:text-primary">{unit.code}</span>
|
||||||
|
<span class="truncate text-sm text-muted">{unit.name}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
25
src/routes/api/+server.ts
Normal file
25
src/routes/api/+server.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { call } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
let fn: string;
|
||||||
|
let args: Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
fn = String(body?.fn ?? '');
|
||||||
|
args = (body?.args ?? {}) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return json({ ok: false, error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = call(fn, args as Record<string, any>);
|
||||||
|
return json({ ok: true, result });
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message ?? 'Internal error';
|
||||||
|
const status = message === 'Not authenticated' || message === 'Not authorized' ? 401 : 400;
|
||||||
|
return json({ ok: false, error: message }, { status });
|
||||||
|
}
|
||||||
|
};
|
||||||
76
src/routes/auth/login/+page.svelte
Normal file
76
src/routes/auth/login/+page.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { login } from '$lib/stores/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let name = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
if (!email.trim() || !name.trim()) {
|
||||||
|
error = 'Please fill in all fields';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.toLowerCase().endsWith('@deakin.edu.au')) {
|
||||||
|
error = 'Only @deakin.edu.au email addresses are allowed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
await login(email.trim(), name.trim());
|
||||||
|
goto('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message ?? 'Login failed. Please try again.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Sign in — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page flex min-h-[calc(100vh-220px)] items-center">
|
||||||
|
<div class="mx-auto w-full max-w-md">
|
||||||
|
<p class="kicker">Account</p>
|
||||||
|
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">Sign in</h1>
|
||||||
|
<p class="mt-2 mb-8 text-[15px] text-muted">Use your Deakin email to contribute.</p>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-5 border-t border-rule pt-8">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="kicker mb-2 block">Full name</label>
|
||||||
|
<input id="name" type="text" bind:value={name} placeholder="Jane Smith" class="field" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="kicker mb-2 block">Deakin email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
placeholder="j.smith@deakin.edu.au"
|
||||||
|
class="field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-xs text-faint">Must be an @deakin.edu.au address</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-primary">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} class="btn-primary w-full">
|
||||||
|
{loading ? 'Signing in...' : 'Sign in / Register'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-faint">By signing in, you agree that your contributions are public.</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
61
src/routes/layout.css
Normal file
61
src/routes/layout.css
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-serif: 'EB Garamond', 'Iowan Old Style', Palatino, Georgia, serif;
|
||||||
|
--color-primary: #ea4c65;
|
||||||
|
--color-primary-dark: #d43d56;
|
||||||
|
--color-secondary: #00bcd4;
|
||||||
|
--color-secondary-dark: #00a5bb;
|
||||||
|
--color-ink: #111111;
|
||||||
|
--color-muted: #737373;
|
||||||
|
--color-faint: #a3a3a3;
|
||||||
|
--color-rule: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: #fff;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
@apply mx-auto max-w-4xl px-4 py-10 sm:px-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
@apply font-sans text-[11px] font-medium uppercase tracking-[0.18em] text-faint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-active {
|
||||||
|
@apply border-primary bg-primary text-white hover:border-primary-dark hover:bg-primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
@apply text-[11px] font-medium uppercase tracking-[0.2em] text-muted transition-colors hover:text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-active {
|
||||||
|
@apply text-ink;
|
||||||
|
}
|
||||||
101
src/routes/notes/+page.svelte
Normal file
101
src/routes/notes/+page.svelte
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query } from '$lib/api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import FeedRow from '$lib/components/FeedRow.svelte';
|
||||||
|
import { timeAgo } from '$lib/time';
|
||||||
|
import type { NoteDoc, UnitDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let notes: (NoteDoc & { unit?: UnitDoc })[] = $state([]);
|
||||||
|
let units: UnitDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let selectedUnitId = $state('');
|
||||||
|
let sort = $state<'newest' | 'top'>('newest');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const [n, u] = await Promise.all([query('notes:list', {}), query('units:getAll')]);
|
||||||
|
const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit]));
|
||||||
|
notes = (n as NoteDoc[]).map((note) => ({ ...note, unit: unitMap.get(note.unitId) }));
|
||||||
|
units = u as UnitDoc[];
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedUnits = $derived.by(() => {
|
||||||
|
const ids = new Set(notes.map((n) => n.unitId));
|
||||||
|
return units.filter((u) => ids.has(u._id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = $derived.by(() => {
|
||||||
|
let list = selectedUnitId ? notes.filter((n) => n.unitId === selectedUnitId) : notes;
|
||||||
|
if (sort === 'top') list = [...list].sort((a, b) => b.voteCount - a.voteCount);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Notes — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="flex items-end justify-between gap-4 border-b border-rule pb-6">
|
||||||
|
<h1 class="font-serif text-4xl font-medium text-ink">Notes</h1>
|
||||||
|
<a href="/post/note" class="btn-primary">Post a note</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-rule py-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
||||||
|
onclick={() => (selectedUnitId = '')}
|
||||||
|
>
|
||||||
|
All units
|
||||||
|
</button>
|
||||||
|
{#each usedUnits as unit}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip {selectedUnitId === unit._id ? 'chip-active' : ''}"
|
||||||
|
onclick={() => (selectedUnitId = unit._id)}
|
||||||
|
>
|
||||||
|
{unit.code}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kicker {sort === 'newest' ? 'text-ink' : ''}"
|
||||||
|
onclick={() => (sort = 'newest')}
|
||||||
|
>
|
||||||
|
Newest
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kicker {sort === 'top' ? 'text-ink' : ''}"
|
||||||
|
onclick={() => (sort = 'top')}
|
||||||
|
>
|
||||||
|
Top
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else}
|
||||||
|
{#each visible as note}
|
||||||
|
<FeedRow
|
||||||
|
href="/notes/{note._id}"
|
||||||
|
title={note.title}
|
||||||
|
unitCode={note.unit?.code}
|
||||||
|
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
||||||
|
voteCount={note.voteCount}
|
||||||
|
targetType="note"
|
||||||
|
targetId={note._id}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="py-16">
|
||||||
|
<p class="text-sm text-muted">No notes yet.</p>
|
||||||
|
<a href="/post/note" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Post a note</a>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
169
src/routes/notes/[id]/+page.svelte
Normal file
169
src/routes/notes/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query, mutation } from '$lib/api';
|
||||||
|
import { isAuthenticated, getToken, currentUser } from '$lib/stores/auth';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import VoteStack from '$lib/components/VoteStack.svelte';
|
||||||
|
import { timeAgo } from '$lib/time';
|
||||||
|
import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let note: NoteDoc | null = $state(null);
|
||||||
|
let topic: TopicDoc | null = $state(null);
|
||||||
|
let unit: UnitDoc | null = $state(null);
|
||||||
|
let comments: CommentDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
let commentText = $state('');
|
||||||
|
let commentError = $state('');
|
||||||
|
let commentLoading = $state(false);
|
||||||
|
let deleteLoading = $state(false);
|
||||||
|
let isAuthor = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const id = page.params.id;
|
||||||
|
const result = await query('details:getNoteWithDetails', { id });
|
||||||
|
if (result) {
|
||||||
|
note = result as NoteDoc;
|
||||||
|
topic = (result as any).topic ?? null;
|
||||||
|
unit = (result as any).unit ?? null;
|
||||||
|
comments = (result as any).comments ?? [];
|
||||||
|
const cu = get(currentUser);
|
||||||
|
if (cu) isAuthor = note.authorId === cu._id;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function postComment() {
|
||||||
|
if (!commentText.trim()) return;
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
commentError = 'Please sign in to comment';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentLoading = true;
|
||||||
|
commentError = '';
|
||||||
|
try {
|
||||||
|
await mutation('comments:createOnNote', {
|
||||||
|
token,
|
||||||
|
content: commentText.trim(),
|
||||||
|
parentId: page.params.id
|
||||||
|
});
|
||||||
|
commentText = '';
|
||||||
|
const updated = await query('details:getNoteWithDetails', { id: page.params.id });
|
||||||
|
if (updated) comments = (updated as any).comments ?? [];
|
||||||
|
} catch (err: any) {
|
||||||
|
commentError = err.message ?? 'Failed to post comment';
|
||||||
|
} finally {
|
||||||
|
commentLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token || !note) return;
|
||||||
|
if (!confirm('Delete this note?')) return;
|
||||||
|
|
||||||
|
deleteLoading = true;
|
||||||
|
try {
|
||||||
|
await mutation('notes:remove', { token, id: note._id });
|
||||||
|
window.location.href = '/notes';
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message ?? 'Failed to delete');
|
||||||
|
} finally {
|
||||||
|
deleteLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{note?.title ?? 'Loading...'} — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else if note}
|
||||||
|
<div class="flex gap-5">
|
||||||
|
<VoteStack count={note.voteCount} targetType="note" targetId={note._id} />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="kicker">
|
||||||
|
{#if unit}{unit.code}{/if}{#if unit && topic} · {/if}{#if topic}{topic.name}{/if}
|
||||||
|
</p>
|
||||||
|
<h1 class="mt-2 font-serif text-3xl font-medium leading-tight text-ink sm:text-4xl">{note.title}</h1>
|
||||||
|
<p class="kicker mt-3">
|
||||||
|
{note.authorName} · {timeAgo(note.createdAt)}
|
||||||
|
{#if isAuthor}
|
||||||
|
·
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={deleteNote}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
class="text-primary hover:text-primary-dark"
|
||||||
|
>
|
||||||
|
{deleteLoading ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 whitespace-pre-wrap border-t border-rule pt-8 text-[15px] leading-relaxed text-ink">
|
||||||
|
{note.content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mt-12 border-t border-rule pt-8">
|
||||||
|
<p class="kicker mb-6">Comments ({comments.length})</p>
|
||||||
|
|
||||||
|
{#if get(isAuthenticated)}
|
||||||
|
<div class="mb-8">
|
||||||
|
<textarea bind:value={commentText} rows={3} placeholder="Add a comment..." class="field resize-y"></textarea>
|
||||||
|
<div class="mt-3 flex items-center justify-between">
|
||||||
|
<span class="text-xs text-primary">{commentError}</span>
|
||||||
|
<button
|
||||||
|
onclick={postComment}
|
||||||
|
disabled={commentLoading || !commentText.trim()}
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{commentLoading ? 'Posting...' : 'Post comment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-8 text-sm text-muted">
|
||||||
|
<a href="/auth/login" class="text-secondary hover:text-secondary-dark">Sign in</a> to leave a comment.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#each comments as comment}
|
||||||
|
<div class="border-t border-rule py-4">
|
||||||
|
<p class="kicker">{comment.authorName} · {timeAgo(comment.createdAt)}</p>
|
||||||
|
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">{comment.content}</p>
|
||||||
|
{#if get(currentUser)?._id === comment.authorId}
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await mutation('comments:remove', { token, id: comment._id });
|
||||||
|
const updated = await query('details:getNoteWithDetails', { id: page.params.id });
|
||||||
|
if (updated) comments = (updated as any).comments ?? [];
|
||||||
|
} catch (e) {}
|
||||||
|
}}
|
||||||
|
class="mt-2 text-xs text-primary hover:text-primary-dark"
|
||||||
|
>Delete</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted">No comments yet.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<h1 class="font-serif text-3xl text-ink">Note not found</h1>
|
||||||
|
<p class="mt-2 text-sm text-muted">This note may have been deleted or doesn't exist.</p>
|
||||||
|
<a href="/notes" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Back to notes</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
179
src/routes/post/note/+page.svelte
Normal file
179
src/routes/post/note/+page.svelte
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query, mutation } from '$lib/api';
|
||||||
|
import { isAuthenticated, getToken } from '$lib/stores/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { TopicDoc, UnitDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let topics: TopicDoc[] = $state([]);
|
||||||
|
let units: UnitDoc[] = $state([]);
|
||||||
|
let title = $state('');
|
||||||
|
let content = $state('');
|
||||||
|
let selectedTopicId = $state('');
|
||||||
|
let selectedUnitId = $state('');
|
||||||
|
let customUnit = $state('');
|
||||||
|
let useCustomUnit = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let success = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!get(isAuthenticated)) {
|
||||||
|
goto('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [t, u] = await Promise.all([query('topics:getAll'), query('units:getAll')]);
|
||||||
|
topics = t as TopicDoc[];
|
||||||
|
units = u as UnitDoc[];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
success = '';
|
||||||
|
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
error = 'Title and content are required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedTopicId) {
|
||||||
|
error = 'Please select a topic';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
error = 'You must be signed in';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
let unitId = selectedUnitId;
|
||||||
|
if (useCustomUnit && customUnit.trim()) {
|
||||||
|
const existing = units.find((u) => u.code.toLowerCase() === customUnit.trim().toUpperCase());
|
||||||
|
if (existing) {
|
||||||
|
unitId = existing._id;
|
||||||
|
} else {
|
||||||
|
unitId = (await mutation('units:createCustom', {
|
||||||
|
code: customUnit.trim().toUpperCase(),
|
||||||
|
name: customUnit.trim().toUpperCase()
|
||||||
|
})) as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutation('notes:create', {
|
||||||
|
token,
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
topicId: selectedTopicId,
|
||||||
|
unitId
|
||||||
|
});
|
||||||
|
|
||||||
|
success = 'Note published.';
|
||||||
|
title = '';
|
||||||
|
content = '';
|
||||||
|
selectedTopicId = '';
|
||||||
|
selectedUnitId = '';
|
||||||
|
customUnit = '';
|
||||||
|
useCustomUnit = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
success = '';
|
||||||
|
}, 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message ?? 'Failed to publish note';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Post a note — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<h1 class="font-serif text-4xl font-medium text-ink">Post a note</h1>
|
||||||
|
<p class="mt-2 mb-10 text-[15px] text-muted">
|
||||||
|
Share study notes with the Deakin community. Focus on a specific topic, not an entire unit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if success}
|
||||||
|
<p class="mb-6 text-sm text-secondary">{success}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6 border-t border-rule pt-8">
|
||||||
|
<div>
|
||||||
|
<label for="title" class="kicker mb-2 block">Title</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
placeholder="e.g., Binary Search Tree Implementation in Python"
|
||||||
|
class="field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="content" class="kicker mb-2 block">Content</label>
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
bind:value={content}
|
||||||
|
rows={10}
|
||||||
|
placeholder="Write your notes here..."
|
||||||
|
class="field resize-y"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="topic" class="kicker mb-2 block">Topic</label>
|
||||||
|
<select id="topic" bind:value={selectedTopicId} class="field" required>
|
||||||
|
<option value="">Select a topic...</option>
|
||||||
|
{#each topics as topic}
|
||||||
|
<option value={topic._id}>{topic.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="unit" class="kicker mb-2 block">Unit</label>
|
||||||
|
{#if !useCustomUnit}
|
||||||
|
<select id="unit" bind:value={selectedUnitId} class="field">
|
||||||
|
<option value="">Select a unit...</option>
|
||||||
|
{#each units as unit}
|
||||||
|
<option value={unit._id}>{unit.code} — {unit.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
useCustomUnit = !useCustomUnit;
|
||||||
|
customUnit = '';
|
||||||
|
}}
|
||||||
|
class="mt-2 text-xs text-secondary hover:text-secondary-dark"
|
||||||
|
>
|
||||||
|
{useCustomUnit ? 'Choose from list' : 'Other unit...'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if useCustomUnit}
|
||||||
|
<input type="text" bind:value={customUnit} placeholder="e.g., SIT384" class="field mt-2" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-primary">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} class="btn-primary">
|
||||||
|
{loading ? 'Publishing...' : 'Publish note'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
177
src/routes/post/question/+page.svelte
Normal file
177
src/routes/post/question/+page.svelte
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query, mutation } from '$lib/api';
|
||||||
|
import { isAuthenticated, getToken } from '$lib/stores/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { TopicDoc, UnitDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let topics: TopicDoc[] = $state([]);
|
||||||
|
let units: UnitDoc[] = $state([]);
|
||||||
|
let title = $state('');
|
||||||
|
let content = $state('');
|
||||||
|
let selectedTopicId = $state('');
|
||||||
|
let selectedUnitId = $state('');
|
||||||
|
let customUnit = $state('');
|
||||||
|
let useCustomUnit = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let success = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!get(isAuthenticated)) {
|
||||||
|
goto('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [t, u] = await Promise.all([query('topics:getAll'), query('units:getAll')]);
|
||||||
|
topics = t as TopicDoc[];
|
||||||
|
units = u as UnitDoc[];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
success = '';
|
||||||
|
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
error = 'Title and content are required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedTopicId) {
|
||||||
|
error = 'Please select a topic';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
error = 'You must be signed in';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
let unitId = selectedUnitId;
|
||||||
|
if (useCustomUnit && customUnit.trim()) {
|
||||||
|
const existing = units.find((u) => u.code.toLowerCase() === customUnit.trim().toUpperCase());
|
||||||
|
if (existing) {
|
||||||
|
unitId = existing._id;
|
||||||
|
} else {
|
||||||
|
unitId = (await mutation('units:createCustom', {
|
||||||
|
code: customUnit.trim().toUpperCase(),
|
||||||
|
name: customUnit.trim().toUpperCase()
|
||||||
|
})) as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutation('questions:create', {
|
||||||
|
token,
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
topicId: selectedTopicId,
|
||||||
|
unitId
|
||||||
|
});
|
||||||
|
|
||||||
|
success = 'Question posted.';
|
||||||
|
title = '';
|
||||||
|
content = '';
|
||||||
|
selectedTopicId = '';
|
||||||
|
selectedUnitId = '';
|
||||||
|
customUnit = '';
|
||||||
|
useCustomUnit = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
success = '';
|
||||||
|
}, 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message ?? 'Failed to post question';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Ask a question — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<h1 class="font-serif text-4xl font-medium text-ink">Ask a question</h1>
|
||||||
|
<p class="mt-2 mb-10 text-[15px] text-muted">Stuck on something? Ask the Deakin community for help.</p>
|
||||||
|
|
||||||
|
{#if success}
|
||||||
|
<p class="mb-6 text-sm text-secondary">{success}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6 border-t border-rule pt-8">
|
||||||
|
<div>
|
||||||
|
<label for="title" class="kicker mb-2 block">Question title</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
placeholder="e.g., How does Dijkstra's algorithm handle negative edge weights?"
|
||||||
|
class="field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="content" class="kicker mb-2 block">Details</label>
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
bind:value={content}
|
||||||
|
rows={10}
|
||||||
|
placeholder="Describe your question in detail..."
|
||||||
|
class="field resize-y"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="topic" class="kicker mb-2 block">Topic</label>
|
||||||
|
<select id="topic" bind:value={selectedTopicId} class="field" required>
|
||||||
|
<option value="">Select a topic...</option>
|
||||||
|
{#each topics as topic}
|
||||||
|
<option value={topic._id}>{topic.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="unit" class="kicker mb-2 block">Unit</label>
|
||||||
|
{#if !useCustomUnit}
|
||||||
|
<select id="unit" bind:value={selectedUnitId} class="field">
|
||||||
|
<option value="">Select a unit...</option>
|
||||||
|
{#each units as unit}
|
||||||
|
<option value={unit._id}>{unit.code} — {unit.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
useCustomUnit = !useCustomUnit;
|
||||||
|
customUnit = '';
|
||||||
|
}}
|
||||||
|
class="mt-2 text-xs text-secondary hover:text-secondary-dark"
|
||||||
|
>
|
||||||
|
{useCustomUnit ? 'Choose from list' : 'Other unit...'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if useCustomUnit}
|
||||||
|
<input type="text" bind:value={customUnit} placeholder="e.g., SIT384" class="field mt-2" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-primary">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} class="btn-primary">
|
||||||
|
{loading ? 'Posting...' : 'Post question'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
104
src/routes/questions/+page.svelte
Normal file
104
src/routes/questions/+page.svelte
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query } from '$lib/api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import FeedRow from '$lib/components/FeedRow.svelte';
|
||||||
|
import { timeAgo } from '$lib/time';
|
||||||
|
import type { QuestionDoc, UnitDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let questions: (QuestionDoc & { unit?: UnitDoc })[] = $state([]);
|
||||||
|
let units: UnitDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let selectedUnitId = $state('');
|
||||||
|
let sort = $state<'newest' | 'top'>('newest');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const [q, u] = await Promise.all([query('questions:list', {}), query('units:getAll')]);
|
||||||
|
const unitMap = new Map((u as UnitDoc[]).map((unit) => [unit._id, unit]));
|
||||||
|
questions = (q as QuestionDoc[]).map((question) => ({
|
||||||
|
...question,
|
||||||
|
unit: unitMap.get(question.unitId)
|
||||||
|
}));
|
||||||
|
units = u as UnitDoc[];
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedUnits = $derived.by(() => {
|
||||||
|
const ids = new Set(questions.map((q) => q.unitId));
|
||||||
|
return units.filter((u) => ids.has(u._id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = $derived.by(() => {
|
||||||
|
let list = selectedUnitId ? questions.filter((q) => q.unitId === selectedUnitId) : questions;
|
||||||
|
if (sort === 'top') list = [...list].sort((a, b) => b.voteCount - a.voteCount);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Questions — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="flex items-end justify-between gap-4 border-b border-rule pb-6">
|
||||||
|
<h1 class="font-serif text-4xl font-medium text-ink">Questions</h1>
|
||||||
|
<a href="/post/question" class="btn-primary">Ask a question</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-rule py-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
||||||
|
onclick={() => (selectedUnitId = '')}
|
||||||
|
>
|
||||||
|
All units
|
||||||
|
</button>
|
||||||
|
{#each usedUnits as unit}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip {selectedUnitId === unit._id ? 'chip-active' : ''}"
|
||||||
|
onclick={() => (selectedUnitId = unit._id)}
|
||||||
|
>
|
||||||
|
{unit.code}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kicker {sort === 'newest' ? 'text-ink' : ''}"
|
||||||
|
onclick={() => (sort = 'newest')}
|
||||||
|
>
|
||||||
|
Newest
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="kicker {sort === 'top' ? 'text-ink' : ''}"
|
||||||
|
onclick={() => (sort = 'top')}
|
||||||
|
>
|
||||||
|
Top
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else}
|
||||||
|
{#each visible as question}
|
||||||
|
<FeedRow
|
||||||
|
href="/questions/{question._id}"
|
||||||
|
title={question.title}
|
||||||
|
unitCode={question.unit?.code}
|
||||||
|
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
||||||
|
voteCount={question.voteCount}
|
||||||
|
targetType="question"
|
||||||
|
targetId={question._id}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="py-16">
|
||||||
|
<p class="text-sm text-muted">No questions yet.</p>
|
||||||
|
<a href="/post/question" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Ask a question</a>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
162
src/routes/questions/[id]/+page.svelte
Normal file
162
src/routes/questions/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query, mutation } from '$lib/api';
|
||||||
|
import { isAuthenticated, getToken, currentUser } from '$lib/stores/auth';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import VoteStack from '$lib/components/VoteStack.svelte';
|
||||||
|
import { timeAgo } from '$lib/time';
|
||||||
|
import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let question: QuestionDoc | null = $state(null);
|
||||||
|
let topic: TopicDoc | null = $state(null);
|
||||||
|
let unit: UnitDoc | null = $state(null);
|
||||||
|
let answers: CommentDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let isAuthor = $state(false);
|
||||||
|
|
||||||
|
let answerText = $state('');
|
||||||
|
let answerError = $state('');
|
||||||
|
let answerLoading = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const id = page.params.id;
|
||||||
|
const result = await query('details:getQuestionWithDetails', { id });
|
||||||
|
if (result) {
|
||||||
|
question = result as QuestionDoc;
|
||||||
|
topic = (result as any).topic ?? null;
|
||||||
|
unit = (result as any).unit ?? null;
|
||||||
|
answers = (result as any).answers ?? [];
|
||||||
|
const cu = get(currentUser);
|
||||||
|
if (cu) isAuthor = question.authorId === cu._id;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function postAnswer() {
|
||||||
|
if (!answerText.trim()) return;
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
answerError = 'Please sign in to answer';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
answerLoading = true;
|
||||||
|
answerError = '';
|
||||||
|
try {
|
||||||
|
await mutation('comments:createOnQuestion', {
|
||||||
|
token,
|
||||||
|
content: answerText.trim(),
|
||||||
|
questionId: page.params.id
|
||||||
|
});
|
||||||
|
answerText = '';
|
||||||
|
const updated = await query('details:getQuestionWithDetails', { id: page.params.id });
|
||||||
|
if (updated) {
|
||||||
|
question = { ...(updated as any), answers: undefined } as QuestionDoc;
|
||||||
|
answers = (updated as any).answers ?? [];
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
answerError = err.message ?? 'Failed to post answer';
|
||||||
|
} finally {
|
||||||
|
answerLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markSolved() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token || !question) return;
|
||||||
|
try {
|
||||||
|
await mutation('questions:markSolved', { token, id: question._id });
|
||||||
|
question = { ...question, solved: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message ?? 'Failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{question?.title ?? 'Loading...'} — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else if question}
|
||||||
|
<div class="flex gap-5">
|
||||||
|
<VoteStack count={question.voteCount} targetType="question" targetId={question._id} />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="kicker">
|
||||||
|
{#if unit}{unit.code}{/if}{#if unit && topic} · {/if}{#if topic}{topic.name}{/if}
|
||||||
|
{#if question.solved} · Solved{/if}
|
||||||
|
</p>
|
||||||
|
<h1 class="mt-2 font-serif text-3xl font-medium leading-tight text-ink sm:text-4xl">{question.title}</h1>
|
||||||
|
<p class="kicker mt-3">
|
||||||
|
{question.authorName} · {timeAgo(question.createdAt)}
|
||||||
|
{#if isAuthor && !question.solved}
|
||||||
|
·
|
||||||
|
<button type="button" onclick={markSolved} class="text-secondary hover:text-secondary-dark">
|
||||||
|
Mark as solved
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 whitespace-pre-wrap border-t border-rule pt-8 text-[15px] leading-relaxed text-ink">
|
||||||
|
{question.content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mt-12 border-t border-rule pt-8">
|
||||||
|
<p class="kicker mb-6">Answers ({answers.length})</p>
|
||||||
|
|
||||||
|
{#if get(isAuthenticated)}
|
||||||
|
<div class="mb-8">
|
||||||
|
<textarea bind:value={answerText} rows={4} placeholder="Write an answer..." class="field resize-y"></textarea>
|
||||||
|
<div class="mt-3 flex items-center justify-between">
|
||||||
|
<span class="text-xs text-primary">{answerError}</span>
|
||||||
|
<button
|
||||||
|
onclick={postAnswer}
|
||||||
|
disabled={answerLoading || !answerText.trim()}
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{answerLoading ? 'Posting...' : 'Post answer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-8 text-sm text-muted">
|
||||||
|
<a href="/auth/login" class="text-secondary hover:text-secondary-dark">Sign in</a> to answer.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#each answers as answer}
|
||||||
|
<div class="border-t border-rule py-4">
|
||||||
|
<p class="kicker">{answer.authorName} · {timeAgo(answer.createdAt)}</p>
|
||||||
|
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">{answer.content}</p>
|
||||||
|
{#if get(currentUser)?._id === answer.authorId}
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await mutation('comments:remove', { token, id: answer._id });
|
||||||
|
const updated = await query('details:getQuestionWithDetails', { id: page.params.id });
|
||||||
|
if (updated) answers = (updated as any).answers ?? [];
|
||||||
|
} catch (e) {}
|
||||||
|
}}
|
||||||
|
class="mt-2 text-xs text-primary hover:text-primary-dark"
|
||||||
|
>Delete</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted">No answers yet.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<h1 class="font-serif text-3xl text-ink">Question not found</h1>
|
||||||
|
<p class="mt-2 text-sm text-muted">This question may have been deleted or doesn't exist.</p>
|
||||||
|
<a href="/questions" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Back to questions</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
59
src/routes/search/+page.svelte
Normal file
59
src/routes/search/+page.svelte
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query } from '$lib/api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import FeedRow from '$lib/components/FeedRow.svelte';
|
||||||
|
import { timeAgo } from '$lib/time';
|
||||||
|
import type { NoteDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let results: NoteDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let ranQuery = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
searchQuery = new URL(window.location.href).searchParams.get('q') ?? '';
|
||||||
|
if (searchQuery) {
|
||||||
|
ranQuery = true;
|
||||||
|
results = (await query('notes:search', { query: searchQuery })) as NoteDoc[];
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{searchQuery ? `Search: ${searchQuery}` : 'Search'} — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<h1 class="font-serif text-4xl font-medium text-ink">Search</h1>
|
||||||
|
<form
|
||||||
|
class="mt-6 mb-8 border-b border-rule pb-8"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = searchQuery.trim();
|
||||||
|
if (!q) return;
|
||||||
|
window.location.href = `/search?q=${encodeURIComponent(q)}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label for="q" class="kicker mb-2 block">Query</label>
|
||||||
|
<input id="q" type="search" bind:value={searchQuery} placeholder="Search notes..." class="field" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else if ranQuery}
|
||||||
|
{#each results as note}
|
||||||
|
<FeedRow
|
||||||
|
href="/notes/{note._id}"
|
||||||
|
title={note.title}
|
||||||
|
meta="{note.authorName} · {timeAgo(note.createdAt)}"
|
||||||
|
voteCount={note.voteCount}
|
||||||
|
targetType="note"
|
||||||
|
targetId={note._id}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted">No results found for “{searchQuery}”.</p>
|
||||||
|
<a href="/notes" class="mt-4 inline-block text-sm text-secondary hover:text-secondary-dark">Browse notes</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
83
src/routes/topics/[slug]/+page.svelte
Normal file
83
src/routes/topics/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query } from '$lib/api';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import FeedRow from '$lib/components/FeedRow.svelte';
|
||||||
|
import { timeAgo } from '$lib/time';
|
||||||
|
import type { NoteDoc, QuestionDoc, TopicDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let topic: TopicDoc | null = $state(null);
|
||||||
|
let notes: NoteDoc[] = $state([]);
|
||||||
|
let questions: QuestionDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const slug = page.params.slug;
|
||||||
|
const t = await query('topics:getBySlug', { slug });
|
||||||
|
topic = t as TopicDoc;
|
||||||
|
if (topic) {
|
||||||
|
const [n, q] = await Promise.all([
|
||||||
|
query('notes:list', { topicId: topic._id }),
|
||||||
|
query('questions:list', { topicId: topic._id })
|
||||||
|
]);
|
||||||
|
notes = n as NoteDoc[];
|
||||||
|
questions = q as QuestionDoc[];
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{topic?.name ?? 'Topic'} — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else if topic}
|
||||||
|
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Topic</p>
|
||||||
|
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">{topic.name}</h1>
|
||||||
|
{#if topic.description}
|
||||||
|
<p class="mt-2 text-[15px] text-muted">{topic.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if notes.length > 0}
|
||||||
|
<p class="kicker mt-10 border-t border-rule pt-8">Notes</p>
|
||||||
|
{#each notes as note}
|
||||||
|
<FeedRow
|
||||||
|
href="/notes/{note._id}"
|
||||||
|
title={note.title}
|
||||||
|
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
||||||
|
voteCount={note.voteCount}
|
||||||
|
targetType="note"
|
||||||
|
targetId={note._id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if questions.length > 0}
|
||||||
|
<p class="kicker mt-10 border-t border-rule pt-8">Questions</p>
|
||||||
|
{#each questions as question}
|
||||||
|
<FeedRow
|
||||||
|
href="/questions/{question._id}"
|
||||||
|
title={question.title}
|
||||||
|
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
||||||
|
voteCount={question.voteCount}
|
||||||
|
targetType="question"
|
||||||
|
targetId={question._id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if notes.length === 0 && questions.length === 0}
|
||||||
|
<p class="mt-10 text-sm text-muted">No content in this topic yet.</p>
|
||||||
|
<div class="mt-4 flex gap-4">
|
||||||
|
<a href="/post/note" class="text-sm text-secondary hover:text-secondary-dark">Post a note</a>
|
||||||
|
<a href="/post/question" class="text-sm text-secondary hover:text-secondary-dark">Ask a question</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<h1 class="font-serif text-3xl text-ink">Topic not found</h1>
|
||||||
|
<a href="/" class="mt-2 inline-block text-sm text-secondary hover:text-secondary-dark">Home</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
86
src/routes/units/[code]/+page.svelte
Normal file
86
src/routes/units/[code]/+page.svelte
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query } from '$lib/api';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import FeedRow from '$lib/components/FeedRow.svelte';
|
||||||
|
import { timeAgo } from '$lib/time';
|
||||||
|
import type { NoteDoc, QuestionDoc, UnitDoc } from '$lib/types';
|
||||||
|
|
||||||
|
let unit: UnitDoc | null = $state(null);
|
||||||
|
let notes: NoteDoc[] = $state([]);
|
||||||
|
let questions: QuestionDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const code = page.params.code;
|
||||||
|
const u = await query('units:getByCode', { code });
|
||||||
|
unit = u as UnitDoc;
|
||||||
|
if (unit) {
|
||||||
|
const [n, q] = await Promise.all([
|
||||||
|
query('notes:list', { unitId: unit._id }),
|
||||||
|
query('questions:list', { unitId: unit._id })
|
||||||
|
]);
|
||||||
|
notes = n as NoteDoc[];
|
||||||
|
questions = q as QuestionDoc[];
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{unit?.code ?? 'Unit'} — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else if unit}
|
||||||
|
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Unit</p>
|
||||||
|
<h1 class="mt-2 font-serif text-4xl font-medium text-ink">{unit.code}</h1>
|
||||||
|
<p class="mt-1 text-[15px] text-muted">{unit.name}</p>
|
||||||
|
{#if unit.description}
|
||||||
|
<p class="mt-2 text-sm text-muted">{unit.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if notes.length > 0}
|
||||||
|
<p class="kicker mt-10 border-t border-rule pt-8">Notes</p>
|
||||||
|
{#each notes as note}
|
||||||
|
<FeedRow
|
||||||
|
href="/notes/{note._id}"
|
||||||
|
title={note.title}
|
||||||
|
unitCode={unit.code}
|
||||||
|
meta="{note.authorName} · {timeAgo(note.createdAt)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
|
||||||
|
voteCount={note.voteCount}
|
||||||
|
targetType="note"
|
||||||
|
targetId={note._id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if questions.length > 0}
|
||||||
|
<p class="kicker mt-10 border-t border-rule pt-8">Questions</p>
|
||||||
|
{#each questions as question}
|
||||||
|
<FeedRow
|
||||||
|
href="/questions/{question._id}"
|
||||||
|
title={question.title}
|
||||||
|
unitCode={unit.code}
|
||||||
|
meta="{question.authorName} · {timeAgo(question.createdAt)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"
|
||||||
|
voteCount={question.voteCount}
|
||||||
|
targetType="question"
|
||||||
|
targetId={question._id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if notes.length === 0 && questions.length === 0}
|
||||||
|
<p class="mt-10 text-sm text-muted">No content for this unit yet.</p>
|
||||||
|
<div class="mt-4 flex gap-4">
|
||||||
|
<a href="/post/note" class="text-sm text-secondary hover:text-secondary-dark">Post a note</a>
|
||||||
|
<a href="/post/question" class="text-sm text-secondary hover:text-secondary-dark">Ask a question</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<h1 class="font-serif text-3xl text-ink">Unit not found</h1>
|
||||||
|
<a href="/" class="mt-2 inline-block text-sm text-secondary hover:text-secondary-dark">Home</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
BIN
static/images/Screenshot 2026-08-11 143425.png
Normal file
BIN
static/images/Screenshot 2026-08-11 143425.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
static/images/Screenshot 2026-08-11 143441.png
Normal file
BIN
static/images/Screenshot 2026-08-11 143441.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
static/images/Screenshot 2026-08-11 143452.png
Normal file
BIN
static/images/Screenshot 2026-08-11 143452.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
static/images/Screenshot 2026-08-11 143506.png
Normal file
BIN
static/images/Screenshot 2026-08-11 143506.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
48
vite.config.ts
Normal file
48
vite.config.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
sveltekit({
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
|
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||||
|
},
|
||||||
|
|
||||||
|
// adapter-node is used because the app persists data to a local SQLite file on the server.
|
||||||
|
adapter: adapter()
|
||||||
|
})
|
||||||
|
],
|
||||||
|
test: {
|
||||||
|
expect: { requireAssertions: true },
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
extends: './vite.config.ts',
|
||||||
|
test: {
|
||||||
|
name: 'client',
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
provider: playwright(),
|
||||||
|
instances: [{ browser: 'chromium', headless: true }]
|
||||||
|
},
|
||||||
|
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||||
|
exclude: ['src/lib/server/**']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
extends: './vite.config.ts',
|
||||||
|
test: {
|
||||||
|
name: 'server',
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue