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