first commit

This commit is contained in:
liyunze 2026-08-29 20:05:06 +08:00
commit 2bb77ea3ac
44 changed files with 5682 additions and 0 deletions

11
.dockerignore Normal file
View 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
View 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
View file

@ -0,0 +1 @@
engine-strict=true

6
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"recommendations": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}

35
Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

32
package.json Normal file
View 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
View file

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

18
src/app.html Normal file
View 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
View 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);
}

View 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

View 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>

View 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>

View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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 });
}
};

View 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
View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

3
static/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

20
tsconfig.json Normal file
View 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
View 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}']
}
}
]
}
});