added admin dashboard

This commit is contained in:
liyunze 2026-08-31 19:56:30 +08:00
parent 35b0d8020d
commit 203dccda52
7 changed files with 1216 additions and 19 deletions

View file

@ -12,6 +12,7 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
- 👍 **Voting** — upvote or downvote notes and questions. - 👍 **Voting** — upvote or downvote notes and questions.
- 🔍 **Search** — find notes by title or content. - 🔍 **Search** — find notes by title or content.
- 🎓 **Deakin-only accounts** — only `@deakin.edu.au` email addresses can contribute. Create an account with a password after verifying your email, then sign in with email and password. - 🎓 **Deakin-only accounts** — only `@deakin.edu.au` email addresses can contribute. Create an account with a password after verifying your email, then sign in with email and password.
- 🛠️ **Admin dashboard** — manage units, accounts, and notes, plus weekly posting statistics. The first person to open the dashboard verifies their email to become the admin.
- 🗂️ **Units & topics** — browse content by Deakin unit code (e.g. `SIT102`, `SIT192`) or topic (e.g. Algorithms, Mathematics). - 🗂️ **Units & topics** — browse content by Deakin unit code (e.g. `SIT102`, `SIT192`) or topic (e.g. Algorithms, Mathematics).
- 💾 **Persistent storage** — all data is stored in a local SQLite database. - 💾 **Persistent storage** — all data is stored in a local SQLite database.
@ -109,6 +110,7 @@ src/
│ ├── units/[code]/ # Unit pages │ ├── units/[code]/ # Unit pages
│ ├── search/ # Search page │ ├── search/ # Search page
│ ├── auth/login/ # Sign in page │ ├── auth/login/ # Sign in page
│ ├── admin/ # Admin dashboard
│ └── api/+server.ts # JSON API endpoint │ └── api/+server.ts # JSON API endpoint
└── app.html └── app.html
``` ```
@ -124,7 +126,7 @@ src/
The SQLite database contains the following tables: The SQLite database contains the following tables:
- `users` — Deakin email accounts - `users` — Deakin email accounts (each has a `role`: `user` or `admin`)
- `units` — Deakin unit codes and names - `units` — Deakin unit codes and names
- `topics` — CS and maths topics - `topics` — CS and maths topics
- `notes` — shared study notes - `notes` — shared study notes

View file

@ -1,21 +1,27 @@
<script lang="ts"> <script lang="ts">
import { isAuthenticated, initAuth, logout } from "$lib/stores/auth"; import { isAuthenticated, initAuth, logout, currentUser } from "$lib/stores/auth";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
let auth = $state(false); let auth = $state(false);
let admin = $state(false);
onMount(() => { onMount(() => {
initAuth(); initAuth();
const unsub = isAuthenticated.subscribe((v) => (auth = v)); const unsubAuth = isAuthenticated.subscribe((v) => (auth = v));
return () => unsub(); const unsubUser = currentUser.subscribe((u) => (admin = u?.role === "admin"));
return () => {
unsubAuth();
unsubUser();
};
}); });
const path = $derived(page.url.pathname); const path = $derived(page.url.pathname);
const onNotes = $derived(path === "/notes" || path.startsWith("/notes/")); const onNotes = $derived(path === "/notes" || path.startsWith("/notes/"));
const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/")); const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/"));
const onAdmin = $derived(path === "/admin" || path.startsWith("/admin/"));
</script> </script>
<header class="border-rule border-b bg-white"> <header class="border-rule border-b bg-white">
@ -27,6 +33,9 @@
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}" <a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}"
>Questions</a >Questions</a
> >
{#if admin}
<a href="/admin" class="nav-link {onAdmin ? 'nav-link-active' : ''}">Admin</a>
{/if}
{#if auth} {#if auth}
<button <button
type="button" type="button"
@ -68,6 +77,11 @@
<a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)} <a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
>Questions</a >Questions</a
> >
{#if admin}
<a href="/admin" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
>Admin</a
>
{/if}
{#if auth} {#if auth}
<button <button
type="button" type="button"

View file

@ -71,23 +71,29 @@ function normalizeEmail(email: string): string {
function requireAuth(db: Db, token: string): Record<string, any> { function requireAuth(db: Db, token: string): Record<string, any> {
const user = db const user = db
.prepare( .prepare(
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?", "SELECT id AS _id, email, name, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
) )
.get(token) as Record<string, any> | undefined; .get(token) as Record<string, any> | undefined;
if (!user) throw new Error("Not authenticated"); if (!user) throw new Error("Not authenticated");
return user; return user;
} }
function requireAdmin(db: Db, token: string): Record<string, any> {
const user = requireAuth(db, token);
if (user.role !== "admin") throw new Error("Not authorized");
return user;
}
function mapQuestion(row: Record<string, any>): Record<string, any> { function mapQuestion(row: Record<string, any>): Record<string, any> {
return { ...row, solved: !!row.solved }; return { ...row, solved: !!row.solved };
} }
// ---- users ---- // ---- users ----
function issueSession(db: Db, user: { _id: string; name: string }) { function issueSession(db: Db, user: { _id: string; name: string; role?: string }) {
const token = generateToken(); const token = generateToken();
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, user._id); db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, user._id);
return { userId: user._id, token, name: user.name }; return { userId: user._id, token, name: user.name, role: user.role ?? "user" };
} }
async function sendCode(db: Db, email: string, name: string, kind: "verification" | "reset") { async function sendCode(db: Db, email: string, name: string, kind: "verification" | "reset") {
@ -171,22 +177,27 @@ function authSignup(db: Db, args: { email: string; code: string; password: strin
const id = newId(); const id = newId();
db.prepare( db.prepare(
"INSERT INTO users (id, email, name, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)", "INSERT INTO users (id, email, name, passwordHash, role, createdAt) VALUES (?, ?, ?, ?, 'user', ?)",
).run(id, email, pending.name, hashPassword(password), Date.now()); ).run(id, email, pending.name, hashPassword(password), Date.now());
return issueSession(db, { _id: id, name: pending.name }); return issueSession(db, { _id: id, name: pending.name, role: "user" });
} }
function authSignin(db: Db, args: { email: string; password: string }) { function authSignin(db: Db, args: { email: string; password: string }) {
const email = normalizeEmail(args.email); const email = normalizeEmail(args.email);
const user = db const user = db
.prepare("SELECT id AS _id, name, passwordHash FROM users WHERE email = ?") .prepare("SELECT id AS _id, name, role, passwordHash FROM users WHERE email = ?")
.get(email) as { _id: string; name: string; passwordHash: string | null } | undefined; .get(email) as
| { _id: string; name: string; role: string; passwordHash: string | null }
| undefined;
if (!user || !user.passwordHash) throw new Error("No account found for this email"); if (!user) throw new Error("No account found for this email");
if (!user.passwordHash) {
throw new Error("This account has no password set. Use Forgot password to create one.");
}
if (!verifyPassword(args.password, user.passwordHash)) throw new Error("Incorrect password"); if (!verifyPassword(args.password, user.passwordHash)) throw new Error("Incorrect password");
return issueSession(db, { _id: user._id, name: user.name }); return issueSession(db, { _id: user._id, name: user.name, role: user.role });
} }
async function authForgotPassword(db: Db, args: { email: string }) { async function authForgotPassword(db: Db, args: { email: string }) {
@ -203,23 +214,23 @@ function authResetPassword(db: Db, args: { email: string; code: string; password
const password = validatePassword(args.password); const password = validatePassword(args.password);
verifyAndConsumeCode(db, email, args.code); verifyAndConsumeCode(db, email, args.code);
const user = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as const user = db
| { _id: string; name: string } .prepare("SELECT id AS _id, name, role FROM users WHERE email = ?")
| undefined; .get(email) as { _id: string; name: string; role: string } | undefined;
if (!user) throw new Error("No account found for this email"); if (!user) throw new Error("No account found for this email");
db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run( db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run(
hashPassword(password), hashPassword(password),
user._id, user._id,
); );
return issueSession(db, { _id: user._id, name: user.name }); return issueSession(db, { _id: user._id, name: user.name, role: user.role });
} }
function usersGetByToken(db: Db, args: { token: string }) { function usersGetByToken(db: Db, args: { token: string }) {
return ( return (
db db
.prepare( .prepare(
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE sessionToken = ?", "SELECT id AS _id, email, name, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
) )
.get(args.token) ?? null .get(args.token) ?? null
); );
@ -563,6 +574,314 @@ function getQuestionWithDetails(db: Db, args: { id: string }) {
return { ...mapQuestion(question), topic, unit, answers }; return { ...mapQuestion(question), topic, unit, answers };
} }
// ---- admin ----
function adminGetState(db: Db, args: { token?: string }) {
const admin = db.prepare("SELECT id AS _id FROM users WHERE role = 'admin' LIMIT 1").get();
let currentUser: Record<string, any> | null = null;
let isAdmin = false;
if (args.token) {
const user = db
.prepare(
"SELECT id AS _id, email, name, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
)
.get(args.token) as Record<string, any> | undefined;
if (user) {
currentUser = user;
isAdmin = user.role === "admin";
}
}
return { hasAdmin: !!admin, currentUser, isAdmin };
}
function ensureNoAdmin(db: Db) {
const admin = db.prepare("SELECT id AS _id FROM users WHERE role = 'admin' LIMIT 1").get();
if (admin) throw new Error("An admin already exists");
}
async function adminRequestCode(db: Db, args: { email: string; name?: string }) {
ensureNoAdmin(db);
const email = normalizeEmail(args.email);
const existing = db.prepare("SELECT name FROM users WHERE email = ?").get(email) as
| { name: string }
| undefined;
const name = (existing?.name ?? args.name ?? "").trim();
if (!name) throw new Error("Name is required");
return await sendCode(db, email, name, "verification");
}
function adminCompleteSetup(db: Db, args: { email: string; code: string }) {
ensureNoAdmin(db);
const email = normalizeEmail(args.email);
const pending = verifyAndConsumeCode(db, email, args.code);
const existing = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as
| { _id: string; name: string }
| undefined;
if (existing) {
db.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(existing._id);
return issueSession(db, { _id: existing._id, name: existing.name, role: "admin" });
}
const id = newId();
db.prepare(
"INSERT INTO users (id, email, name, role, createdAt) VALUES (?, ?, ?, 'admin', ?)",
).run(id, email, pending.name, Date.now());
return issueSession(db, { _id: id, name: pending.name, role: "admin" });
}
function adminUnitsSave(
db: Db,
args: { token: string; id?: string; code: string; name: string; description?: string },
) {
requireAdmin(db, args.token);
const code = (args.code ?? "").trim().toUpperCase();
const name = (args.name ?? "").trim();
if (!code || !name) throw new Error("Code and name are required");
const duplicate = db
.prepare("SELECT id AS _id FROM units WHERE code = ? AND id != ?")
.get(code, args.id ?? "") as { _id: string } | undefined;
if (duplicate) throw new Error("A unit with this code already exists");
const description = args.description?.trim() || null;
if (args.id) {
const existing = db.prepare("SELECT id AS _id FROM units WHERE id = ?").get(args.id);
if (!existing) throw new Error("Unit not found");
db.prepare("UPDATE units SET code = ?, name = ?, description = ? WHERE id = ?").run(
code,
name,
description,
args.id,
);
return args.id;
}
const id = newId();
db.prepare("INSERT INTO units (id, code, name, description) VALUES (?, ?, ?, ?)").run(
id,
code,
name,
description,
);
return id;
}
function adminUnitsDelete(db: Db, args: { token: string; id: string }) {
requireAdmin(db, args.token);
const refs = db
.prepare(
"SELECT (SELECT COUNT(*) FROM notes WHERE unitId = ?) + (SELECT COUNT(*) FROM questions WHERE unitId = ?) AS c",
)
.get(args.id, args.id) as { c: number };
if (refs.c > 0) throw new Error("Cannot delete a unit that has notes or questions");
db.prepare("DELETE FROM units WHERE id = ?").run(args.id);
}
function adminUsersList(db: Db, args: { token: string }) {
requireAdmin(db, args.token);
return db
.prepare(
`SELECT
u.id AS _id,
u.email,
u.name,
u.role,
u.createdAt AS _creationTime,
(SELECT COUNT(*) FROM notes n WHERE n.authorId = u.id) AS noteCount,
(SELECT COUNT(*) FROM questions q WHERE q.authorId = u.id) AS questionCount
FROM users u
ORDER BY u.createdAt ASC`,
)
.all();
}
function adminUsersUpdate(
db: Db,
args: { token: string; id: string; email: string; name: string; role: string },
) {
requireAdmin(db, args.token);
const user = db.prepare("SELECT id AS _id, role FROM users WHERE id = ?").get(args.id) as
| { _id: string; role: string }
| undefined;
if (!user) throw new Error("User not found");
const email = normalizeEmail(args.email);
const name = (args.name ?? "").trim();
if (!name) throw new Error("Name is required");
const duplicate = db
.prepare("SELECT id AS _id FROM users WHERE email = ? AND id != ?")
.get(email, args.id) as { _id: string } | undefined;
if (duplicate) throw new Error("Email is already in use");
const role = args.role === "admin" ? "admin" : "user";
if (user.role === "admin" && role !== "admin") {
const adminCount = db
.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
.get() as { c: number };
if (adminCount.c <= 1) throw new Error("Cannot demote the last admin");
}
db.prepare("UPDATE users SET email = ?, name = ?, role = ? WHERE id = ?").run(
email,
name,
role,
args.id,
);
}
function adminUsersDelete(db: Db, args: { token: string; id: string }) {
const admin = requireAdmin(db, args.token);
const user = db
.prepare("SELECT id AS _id, role, email FROM users WHERE id = ?")
.get(args.id) as { _id: string; role: string; email: string } | undefined;
if (!user) throw new Error("User not found");
if (user._id === admin._id) throw new Error("You cannot delete your own account");
if (user.role === "admin") {
const adminCount = db
.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
.get() as { c: number };
if (adminCount.c <= 1) throw new Error("Cannot delete the last admin");
}
const noteIds = (
db.prepare("SELECT id FROM notes WHERE authorId = ?").all(args.id) as { id: string }[]
).map((r) => r.id);
const questionIds = (
db.prepare("SELECT id FROM questions WHERE authorId = ?").all(args.id) as {
id: string;
}[]
).map((r) => r.id);
db.exec("BEGIN");
try {
db.prepare("DELETE FROM votes WHERE userId = ?").run(args.id);
for (const id of noteIds) {
db.prepare("DELETE FROM votes WHERE targetType = 'note' AND targetId = ?").run(id);
db.prepare("DELETE FROM comments WHERE parentId = ?").run(id);
}
for (const id of questionIds) {
db.prepare("DELETE FROM votes WHERE targetType = 'question' AND targetId = ?").run(id);
db.prepare("DELETE FROM comments WHERE questionId = ?").run(id);
}
db.prepare("DELETE FROM comments WHERE authorId = ?").run(args.id);
db.prepare("DELETE FROM notes WHERE authorId = ?").run(args.id);
db.prepare("DELETE FROM questions WHERE authorId = ?").run(args.id);
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(user.email);
db.prepare("DELETE FROM users WHERE id = ?").run(args.id);
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
}
function adminNotesList(db: Db, args: { token: string }) {
requireAdmin(db, args.token);
return db
.prepare(
`SELECT
n.id AS _id,
n.title,
n.content,
n.topicId,
n.unitId,
n.authorId,
n.authorName,
n.createdAt,
n.updatedAt,
n.voteCount,
n.commentCount,
u.code AS unitCode
FROM notes n
LEFT JOIN units u ON u.id = n.unitId
ORDER BY n.createdAt DESC`,
)
.all();
}
function adminNotesUpdate(
db: Db,
args: { token: string; id: string; title: string; content: string },
) {
requireAdmin(db, args.token);
const title = (args.title ?? "").trim();
const content = (args.content ?? "").trim();
if (!title || !content) throw new Error("Title and content are required");
db.prepare("UPDATE notes SET title = ?, content = ?, updatedAt = ? WHERE id = ?").run(
title,
content,
Date.now(),
args.id,
);
}
function adminNotesDelete(db: Db, args: { token: string; id: string }) {
requireAdmin(db, args.token);
db.prepare("DELETE FROM comments WHERE parentId = ?").run(args.id);
db.prepare("DELETE FROM votes WHERE targetType = 'note' AND targetId = ?").run(args.id);
db.prepare("DELETE FROM notes WHERE id = ?").run(args.id);
}
function startOfUtcWeek(ts: number): number {
const date = new Date(ts);
const day = date.getUTCDay();
const diffToMonday = (day + 6) % 7;
date.setUTCHours(0, 0, 0, 0);
date.setUTCDate(date.getUTCDate() - diffToMonday);
return date.getTime();
}
function adminStats(db: Db, args: { token: string; weeks?: number }) {
requireAdmin(db, args.token);
const weekCount = Math.max(1, Math.min(52, args.weeks ?? 8));
const weekMs = 7 * 24 * 60 * 60 * 1000;
const now = Date.now();
const currentWeekStart = startOfUtcWeek(now);
const firstWeekStart = currentWeekStart - (weekCount - 1) * weekMs;
const notes = db
.prepare("SELECT createdAt FROM notes WHERE createdAt >= ?")
.all(firstWeekStart) as { createdAt: number }[];
const questions = db
.prepare("SELECT createdAt FROM questions WHERE createdAt >= ?")
.all(firstWeekStart) as { createdAt: number }[];
const buckets = new Map<number, { notes: number; questions: number }>();
for (let i = 0; i < weekCount; i++) {
buckets.set(firstWeekStart + i * weekMs, { notes: 0, questions: 0 });
}
for (const row of notes) {
const start = startOfUtcWeek(row.createdAt);
const bucket = buckets.get(start);
if (bucket) bucket.notes++;
}
for (const row of questions) {
const start = startOfUtcWeek(row.createdAt);
const bucket = buckets.get(start);
if (bucket) bucket.questions++;
}
const weeks = [...buckets.entries()]
.sort((a, b) => a[0] - b[0])
.map(([weekStart, counts]) => ({ weekStart, ...counts }));
const totals = {
notes: (db.prepare("SELECT COUNT(*) AS c FROM notes").get() as { c: number }).c,
questions: (db.prepare("SELECT COUNT(*) AS c FROM questions").get() as { c: number }).c,
users: (db.prepare("SELECT COUNT(*) AS c FROM users").get() as { c: number }).c,
units: (db.prepare("SELECT COUNT(*) AS c FROM units").get() as { c: number }).c,
};
return { weeks, totals };
}
// ---- dispatcher ---- // ---- dispatcher ----
type Handler = (db: Db, args: any) => any; type Handler = (db: Db, args: any) => any;
@ -597,6 +916,18 @@ const handlers: Record<string, Handler> = {
"votes:cast": votesCast, "votes:cast": votesCast,
"details:getNoteWithDetails": getNoteWithDetails, "details:getNoteWithDetails": getNoteWithDetails,
"details:getQuestionWithDetails": getQuestionWithDetails, "details:getQuestionWithDetails": getQuestionWithDetails,
"admin:getState": adminGetState,
"admin:requestCode": adminRequestCode,
"admin:completeSetup": adminCompleteSetup,
"admin:unitsSave": adminUnitsSave,
"admin:unitsDelete": adminUnitsDelete,
"admin:usersList": adminUsersList,
"admin:usersUpdate": adminUsersUpdate,
"admin:usersDelete": adminUsersDelete,
"admin:notesList": adminNotesList,
"admin:notesUpdate": adminNotesUpdate,
"admin:notesDelete": adminNotesDelete,
"admin:stats": adminStats,
}; };
export async function call(fn: string, args: Record<string, any> = {}): Promise<any> { export async function call(fn: string, args: Record<string, any> = {}): Promise<any> {

View file

@ -16,6 +16,7 @@ function createSchema(database: DatabaseSync) {
name TEXT NOT NULL, name TEXT NOT NULL,
sessionToken TEXT, sessionToken TEXT,
passwordHash TEXT, passwordHash TEXT,
role TEXT NOT NULL DEFAULT 'user',
createdAt INTEGER NOT NULL createdAt INTEGER NOT NULL
); );
@ -210,6 +211,9 @@ function migrate(database: DatabaseSync) {
if (!columns.some((c) => c.name === "passwordHash")) { if (!columns.some((c) => c.name === "passwordHash")) {
database.exec("ALTER TABLE users ADD COLUMN passwordHash TEXT"); database.exec("ALTER TABLE users ADD COLUMN passwordHash TEXT");
} }
if (!columns.some((c) => c.name === "role")) {
database.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
}
} }
function seed(database: DatabaseSync) { function seed(database: DatabaseSync) {

View file

@ -31,13 +31,17 @@ export function initAuth(): Promise<void> {
return initPromise; return initPromise;
} }
function setSession(result: { userId: string; token: string; name: string }, email: string) { function setSession(
result: { userId: string; token: string; name: string; role?: string },
email: string,
) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token })); localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token }));
currentUser.set({ currentUser.set({
_id: result.userId, _id: result.userId,
email: email.toLowerCase(), email: email.toLowerCase(),
name: result.name, name: result.name,
sessionToken: result.token, sessionToken: result.token,
role: (result.role ?? "user") as UserDoc["role"],
_creationTime: Date.now(), _creationTime: Date.now(),
} as UserDoc); } as UserDoc);
isAuthenticated.set(true); isAuthenticated.set(true);
@ -67,6 +71,15 @@ export async function resetPassword(email: string, code: string, password: strin
return setSession(result, email); return setSession(result, email);
} }
export async function adminRequestCode(email: string, name: string) {
return await mutation("admin:requestCode", { email, name });
}
export async function adminCompleteSetup(email: string, code: string) {
const result = await mutation("admin:completeSetup", { email, code });
return setSession(result, email);
}
export function getToken(): string | null { export function getToken(): string | null {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null; if (!stored) return null;

View file

@ -9,6 +9,7 @@ export type UserDoc = Doc<"users"> & {
email: string; email: string;
name: string; name: string;
sessionToken?: string; sessionToken?: string;
role: "user" | "admin";
}; };
export type TopicDoc = Doc<"topics"> & { export type TopicDoc = Doc<"topics"> & {

View file

@ -0,0 +1,832 @@
<script lang="ts">
import { onMount } from "svelte";
import { query, mutation } from "$lib/api";
import { adminCompleteSetup, adminRequestCode, getToken, initAuth } from "$lib/stores/auth";
import { timeAgo } from "$lib/time";
import type { UnitDoc, UserDoc } from "$lib/types";
type Tab = "overview" | "units" | "accounts" | "notes";
type AdminState = {
hasAdmin: boolean;
currentUser: UserDoc | null;
isAdmin: boolean;
};
type WeekStat = { weekStart: number; notes: number; questions: number };
type Stats = {
weeks: WeekStat[];
totals: { notes: number; questions: number; users: number; units: number };
};
type AdminUser = UserDoc & { noteCount: number; questionCount: number };
type AdminNote = {
_id: string;
title: string;
content: string;
unitId: string;
authorName: string;
createdAt: number;
unitCode?: string;
voteCount: number;
commentCount: number;
};
let loading = $state(true);
let adminState = $state<AdminState | null>(null);
let tab = $state<Tab>("overview");
let token = "";
// First-time admin setup
let setupStep = $state<"email" | "code">("email");
let setupEmail = $state("");
let setupName = $state("");
let setupCode = $state("");
let setupError = $state("");
let setupLoading = $state(false);
// Dashboard data
let stats = $state<Stats | null>(null);
let units = $state<UnitDoc[]>([]);
let users = $state<AdminUser[]>([]);
let notes = $state<AdminNote[]>([]);
let pageError = $state("");
// Unit editor
let unitForm = $state({ id: "", code: "", name: "", description: "" });
let unitBusy = $state(false);
let unitError = $state("");
let unitSuccess = $state("");
// Account editor
let userForm = $state<{ id: string; email: string; name: string; role: string } | null>(null);
let userBusy = $state(false);
let userError = $state("");
// Note editor
let noteForm = $state<{ id: string; title: string; content: string } | null>(null);
let noteBusy = $state(false);
let noteError = $state("");
const isDeakin = (value: string) => value.toLowerCase().endsWith("@deakin.edu.au");
const maxCount = $derived(
stats ? Math.max(1, ...stats.weeks.map((w) => Math.max(w.notes, w.questions))) : 1,
);
function adminToken(): string {
return getToken() ?? token;
}
function barHeight(value: number): string {
return `${Math.round((value / maxCount) * 128)}px`;
}
function weekLabel(ts: number): string {
const d = new Date(ts);
return `${d.getDate()} ${d.toLocaleDateString("en-AU", { month: "short" })}`;
}
async function loadDashboard() {
const t = getToken();
const state = (await query("admin:getState", t ? { token: t } : {})) as AdminState;
adminState = state;
if (state.hasAdmin && state.isAdmin && t) {
token = t;
const [s, u, us, n] = await Promise.all([
query("admin:stats", { token: t, weeks: 8 }),
query("units:getAll"),
query("admin:usersList", { token: t }),
query("admin:notesList", { token: t }),
]);
stats = s as Stats;
units = u as UnitDoc[];
users = us as AdminUser[];
notes = n as AdminNote[];
}
}
onMount(async () => {
await initAuth();
try {
await loadDashboard();
} catch (err: any) {
pageError = err.message ?? "Failed to load the dashboard";
} finally {
loading = false;
}
});
async function handleSetupSubmit(e: SubmitEvent) {
e.preventDefault();
setupError = "";
if (setupStep === "email") {
if (!setupEmail.trim()) {
setupError = "Please enter your Deakin email";
return;
}
if (!isDeakin(setupEmail)) {
setupError = "Only @deakin.edu.au email addresses are allowed";
return;
}
setupLoading = true;
try {
await adminRequestCode(setupEmail.trim(), setupName.trim());
setupStep = "code";
} catch (err: any) {
setupError = err.message ?? "Failed to send the code";
} finally {
setupLoading = false;
}
return;
}
if (!setupCode.trim()) {
setupError = "Please enter the verification code";
return;
}
setupLoading = true;
try {
await adminCompleteSetup(setupEmail.trim(), setupCode.trim());
await loadDashboard();
} catch (err: any) {
setupError = err.message ?? "Failed to verify the code";
} finally {
setupLoading = false;
}
}
// ---- units ----
function startEditUnit(unit: UnitDoc) {
unitForm = {
id: unit._id,
code: unit.code,
name: unit.name,
description: unit.description ?? "",
};
unitError = "";
unitSuccess = "";
}
function resetUnitForm() {
unitForm = { id: "", code: "", name: "", description: "" };
unitError = "";
unitSuccess = "";
}
async function saveUnit(e: SubmitEvent) {
e.preventDefault();
unitError = "";
unitSuccess = "";
unitBusy = true;
try {
await mutation("admin:unitsSave", {
token: adminToken(),
id: unitForm.id || undefined,
code: unitForm.code,
name: unitForm.name,
description: unitForm.description,
});
units = (await query("units:getAll")) as UnitDoc[];
unitSuccess = unitForm.id ? "Unit updated." : "Unit created.";
resetUnitForm();
} catch (err: any) {
unitError = err.message ?? "Failed to save unit";
} finally {
unitBusy = false;
}
}
async function deleteUnit(unit: UnitDoc) {
if (!confirm(`Delete unit ${unit.code}?`)) return;
unitError = "";
unitSuccess = "";
try {
await mutation("admin:unitsDelete", { token: adminToken(), id: unit._id });
units = (await query("units:getAll")) as UnitDoc[];
} catch (err: any) {
unitError = err.message ?? "Failed to delete unit";
}
}
// ---- accounts ----
function startEditUser(user: AdminUser) {
userForm = { id: user._id, email: user.email, name: user.name, role: user.role };
userError = "";
}
function resetUserForm() {
userForm = null;
userError = "";
}
async function saveUser(e: SubmitEvent) {
e.preventDefault();
if (!userForm) return;
userError = "";
userBusy = true;
try {
await mutation("admin:usersUpdate", {
token: adminToken(),
id: userForm.id,
email: userForm.email,
name: userForm.name,
role: userForm.role,
});
users = (await query("admin:usersList", { token: adminToken() })) as AdminUser[];
resetUserForm();
} catch (err: any) {
userError = err.message ?? "Failed to update account";
} finally {
userBusy = false;
}
}
async function toggleRole(user: AdminUser) {
userError = "";
try {
await mutation("admin:usersUpdate", {
token: adminToken(),
id: user._id,
email: user.email,
name: user.name,
role: user.role === "admin" ? "user" : "admin",
});
users = (await query("admin:usersList", { token: adminToken() })) as AdminUser[];
} catch (err: any) {
userError = err.message ?? "Failed to change role";
}
}
async function deleteUser(user: AdminUser) {
if (!confirm(`Delete ${user.email} and all of their content?`)) return;
userError = "";
try {
await mutation("admin:usersDelete", { token: adminToken(), id: user._id });
users = (await query("admin:usersList", { token: adminToken() })) as AdminUser[];
} catch (err: any) {
userError = err.message ?? "Failed to delete account";
}
}
// ---- notes ----
function startEditNote(note: AdminNote) {
noteForm = { id: note._id, title: note.title, content: note.content };
noteError = "";
}
function resetNoteForm() {
noteForm = null;
noteError = "";
}
async function saveNote(e: SubmitEvent) {
e.preventDefault();
if (!noteForm) return;
noteError = "";
noteBusy = true;
try {
await mutation("admin:notesUpdate", {
token: adminToken(),
id: noteForm.id,
title: noteForm.title,
content: noteForm.content,
});
notes = (await query("admin:notesList", { token: adminToken() })) as AdminNote[];
resetNoteForm();
} catch (err: any) {
noteError = err.message ?? "Failed to update note";
} finally {
noteBusy = false;
}
}
async function deleteNote(note: AdminNote) {
if (!confirm(`Delete note "${note.title}"?`)) return;
noteError = "";
try {
await mutation("admin:notesDelete", { token: adminToken(), id: note._id });
notes = (await query("admin:notesList", { token: adminToken() })) as AdminNote[];
} catch (err: any) {
noteError = err.message ?? "Failed to delete note";
}
}
</script>
<svelte:head>
<title>Admin — Notebook</title>
</svelte:head>
<div class="page">
{#if loading}
<p class="kicker py-16">Loading</p>
{:else if !adminState}
<h1 class="text-ink font-serif text-3xl">Unable to load the dashboard</h1>
<p class="text-muted mt-2 text-sm">{pageError}</p>
{:else if !adminState.hasAdmin}
<div class="mx-auto max-w-md">
<p class="kicker">Admin setup</p>
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">Create the first admin</h1>
<p class="text-muted mt-2 mb-8 text-[15px]">
This notebook doesn't have an admin yet. Verify your Deakin email to take ownership
of the dashboard.
</p>
{#if setupStep === "email"}
<form onsubmit={handleSetupSubmit} class="border-rule space-y-5 border-t pt-8">
<div>
<label for="setup-email" class="kicker mb-2 block">Deakin email</label>
<input
id="setup-email"
type="email"
bind:value={setupEmail}
placeholder="@deakin.edu.au"
class="field"
required
/>
</div>
<div>
<label for="setup-name" class="kicker mb-2 block">Full name</label>
<input
id="setup-name"
type="text"
bind:value={setupName}
placeholder="Only needed for a new account"
class="field"
/>
</div>
{#if setupError}
<p class="text-primary text-sm">{setupError}</p>
{/if}
<button type="submit" disabled={setupLoading} class="btn-primary w-full">
{setupLoading ? "Sending code..." : "Send verification code"}
</button>
</form>
{:else}
<form onsubmit={handleSetupSubmit} class="border-rule space-y-5 border-t pt-8">
<p class="text-muted text-sm">
We sent a 6-digit code to <span class="text-ink">{setupEmail}</span>. It
expires in 10 minutes.
</p>
<div>
<label for="setup-code" class="kicker mb-2 block">Verification code</label>
<input
id="setup-code"
type="text"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
bind:value={setupCode}
placeholder="000000"
class="field"
required
/>
</div>
{#if setupError}
<p class="text-primary text-sm">{setupError}</p>
{/if}
<button type="submit" disabled={setupLoading} class="btn-primary w-full">
{setupLoading ? "Verifying..." : "Verify and become admin"}
</button>
<button
type="button"
onclick={() => {
setupStep = "email";
setupCode = "";
setupError = "";
}}
class="text-muted hover:text-ink text-sm"
>
Use a different email
</button>
</form>
{/if}
</div>
{:else if !adminState.isAdmin}
<h1 class="text-ink font-serif text-4xl font-medium">Admin</h1>
<p class="text-muted mt-2 text-sm">You need to be an admin to view this page.</p>
{#if !adminState.currentUser}
<a
href="/auth/login"
class="text-secondary hover:text-secondary-dark mt-4 inline-block text-sm"
>
Sign in
</a>
{/if}
{:else}
<div class="border-rule flex items-end justify-between gap-4 border-b pb-6">
<div>
<p class="kicker">Admin</p>
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">Dashboard</h1>
</div>
<div class="flex gap-2">
<button
type="button"
class="chip {tab === 'overview' ? 'chip-active' : ''}"
onclick={() => (tab = "overview")}
>
Overview
</button>
<button
type="button"
class="chip {tab === 'units' ? 'chip-active' : ''}"
onclick={() => (tab = "units")}
>
Units
</button>
<button
type="button"
class="chip {tab === 'accounts' ? 'chip-active' : ''}"
onclick={() => (tab = "accounts")}
>
Accounts
</button>
<button
type="button"
class="chip {tab === 'notes' ? 'chip-active' : ''}"
onclick={() => (tab = "notes")}
>
Notes
</button>
</div>
</div>
{#if pageError}
<p class="text-primary mt-4 text-sm">{pageError}</p>
{/if}
{#if tab === "overview"}
{#if stats}
<div class="grid grid-cols-2 gap-4 py-8 lg:grid-cols-4">
<div class="border-rule border p-5">
<p class="kicker">Notes</p>
<p class="text-ink mt-2 font-serif text-4xl">{stats.totals.notes}</p>
</div>
<div class="border-rule border p-5">
<p class="kicker">Questions</p>
<p class="text-ink mt-2 font-serif text-4xl">{stats.totals.questions}</p>
</div>
<div class="border-rule border p-5">
<p class="kicker">Accounts</p>
<p class="text-ink mt-2 font-serif text-4xl">{stats.totals.users}</p>
</div>
<div class="border-rule border p-5">
<p class="kicker">Units</p>
<p class="text-ink mt-2 font-serif text-4xl">{stats.totals.units}</p>
</div>
</div>
<div class="border-rule border-t pt-8">
<div class="flex items-center justify-between">
<p class="kicker">Posts — last {stats.weeks.length} weeks</p>
<div class="flex gap-4 text-[11px] tracking-[0.14em] uppercase">
<span class="text-ink"><span class="text-primary"></span> Notes</span>
<span class="text-ink"
><span class="text-secondary"></span> Questions</span
>
</div>
</div>
<div class="mt-6 flex items-end gap-3 overflow-x-auto pb-2">
{#each stats.weeks as week}
<div class="flex min-w-[72px] flex-1 flex-col items-center gap-1">
<div class="flex h-32 w-full items-end justify-center gap-1">
<div
class="bg-primary w-1/3 max-w-6"
style="height: {barHeight(week.notes)}"
title="{week.notes} notes"
></div>
<div
class="bg-secondary w-1/3 max-w-6"
style="height: {barHeight(week.questions)}"
title="{week.questions} questions"
></div>
</div>
<span class="kicker">{weekLabel(week.weekStart)}</span>
<span class="text-faint text-[10px]"
>{week.notes} notes · {week.questions} questions</span
>
</div>
{/each}
</div>
</div>
{/if}
{:else if tab === "units"}
<div class="grid gap-10 py-8 lg:grid-cols-[1fr_340px]">
<div>
<div class="flex items-baseline justify-between">
<p class="kicker">All units</p>
<span class="text-faint text-xs">{units.length} total</span>
</div>
<div class="border-rule mt-4 border-t">
{#each units as unit}
<div
class="border-rule flex items-center justify-between gap-4 border-b py-3"
>
<div class="min-w-0">
<p class="text-ink font-medium">{unit.code}</p>
<p class="text-muted truncate text-sm">{unit.name}</p>
</div>
<div class="flex shrink-0 gap-3">
<button
type="button"
class="text-secondary hover:text-secondary-dark text-[11px] font-medium tracking-[0.14em] uppercase"
onclick={() => startEditUnit(unit)}
>
Edit
</button>
<button
type="button"
class="text-primary text-[11px] font-medium tracking-[0.14em] uppercase"
onclick={() => deleteUnit(unit)}
>
Delete
</button>
</div>
</div>
{/each}
</div>
</div>
<form onsubmit={saveUnit} class="border-rule h-fit space-y-5 border p-5">
<p class="kicker">{unitForm.id ? "Edit unit" : "Add unit"}</p>
<div>
<label for="unit-code" class="kicker mb-2 block">Code</label>
<input
id="unit-code"
type="text"
bind:value={unitForm.code}
placeholder="e.g., SIT102"
class="field"
required
/>
</div>
<div>
<label for="unit-name" class="kicker mb-2 block">Name</label>
<input
id="unit-name"
type="text"
bind:value={unitForm.name}
placeholder="Unit name"
class="field"
required
/>
</div>
<div>
<label for="unit-description" class="kicker mb-2 block">Description</label>
<textarea
id="unit-description"
bind:value={unitForm.description}
rows={3}
placeholder="Optional"
class="field resize-y"></textarea>
</div>
{#if unitError}
<p class="text-primary text-sm">{unitError}</p>
{/if}
{#if unitSuccess}
<p class="text-secondary text-sm">{unitSuccess}</p>
{/if}
<div class="flex gap-2">
<button type="submit" disabled={unitBusy} class="btn-primary flex-1">
{unitBusy ? "Saving..." : unitForm.id ? "Save changes" : "Create unit"}
</button>
{#if unitForm.id}
<button type="button" onclick={resetUnitForm} class="btn-ghost">
Cancel
</button>
{/if}
</div>
</form>
</div>
{:else if tab === "accounts"}
<div class="grid gap-10 py-8 lg:grid-cols-[1fr_340px]">
<div>
<div class="flex items-baseline justify-between">
<p class="kicker">All accounts</p>
<span class="text-faint text-xs">{users.length} total</span>
</div>
<div class="border-rule mt-4 border-t">
{#each users as user}
<div class="border-rule border-b py-3">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<p class="text-ink font-medium">{user.name}</p>
<p class="text-muted truncate text-sm">{user.email}</p>
</div>
<span class="chip {user.role === 'admin' ? 'chip-active' : ''}">
{user.role}
</span>
</div>
<div class="mt-2 flex flex-wrap items-center gap-3 text-xs">
<span class="text-faint"
>{user.noteCount} note{user.noteCount === 1
? ""
: "s"}</span
>
<span class="text-faint"
>{user.questionCount} question{user.questionCount === 1
? ""
: "s"}</span
>
<div class="ml-auto flex gap-3">
<button
type="button"
class="text-secondary hover:text-secondary-dark text-[11px] font-medium tracking-[0.14em] uppercase"
onclick={() => toggleRole(user)}
>
{user.role === "admin" ? "Make user" : "Make admin"}
</button>
<button
type="button"
class="text-secondary hover:text-secondary-dark text-[11px] font-medium tracking-[0.14em] uppercase"
onclick={() => startEditUser(user)}
>
Edit
</button>
<button
type="button"
class="text-primary text-[11px] font-medium tracking-[0.14em] uppercase"
onclick={() => deleteUser(user)}
>
Delete
</button>
</div>
</div>
</div>
{/each}
</div>
{#if userError}
<p class="text-primary mt-4 text-sm">{userError}</p>
{/if}
</div>
<div class="border-rule h-fit border p-5">
{#if userForm}
<form onsubmit={saveUser} class="space-y-5">
<p class="kicker">Edit account</p>
<div>
<label for="user-name" class="kicker mb-2 block">Name</label>
<input
id="user-name"
type="text"
bind:value={userForm.name}
class="field"
required
/>
</div>
<div>
<label for="user-email" class="kicker mb-2 block"
>Deakin email</label
>
<input
id="user-email"
type="email"
bind:value={userForm.email}
class="field"
required
/>
</div>
<div>
<label for="user-role" class="kicker mb-2 block">Role</label>
<select id="user-role" bind:value={userForm.role} class="field">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
{#if userError}
<p class="text-primary text-sm">{userError}</p>
{/if}
<div class="flex gap-2">
<button
type="submit"
disabled={userBusy}
class="btn-primary flex-1"
>
{userBusy ? "Saving..." : "Save changes"}
</button>
<button type="button" onclick={resetUserForm} class="btn-ghost">
Cancel
</button>
</div>
</form>
{:else}
<p class="kicker">Account editor</p>
<p class="text-muted mt-4 text-sm">
Select an account and choose Edit to change its name, email, or role.
</p>
{/if}
</div>
</div>
{:else if tab === "notes"}
<div class="grid gap-10 py-8 lg:grid-cols-[1fr_340px]">
<div>
<div class="flex items-baseline justify-between">
<p class="kicker">All notes</p>
<span class="text-faint text-xs">{notes.length} total</span>
</div>
<div class="border-rule mt-4 border-t">
{#each notes as note}
<div class="border-rule border-b py-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-ink font-medium">{note.title}</p>
<p class="text-muted mt-1 truncate text-sm">
{note.unitCode ?? "—"} · {note.authorName} · {timeAgo(
note.createdAt,
)}
</p>
</div>
<div class="flex shrink-0 gap-3">
<button
type="button"
class="text-secondary hover:text-secondary-dark text-[11px] font-medium tracking-[0.14em] uppercase"
onclick={() => startEditNote(note)}
>
Edit
</button>
<button
type="button"
class="text-primary text-[11px] font-medium tracking-[0.14em] uppercase"
onclick={() => deleteNote(note)}
>
Delete
</button>
</div>
</div>
</div>
{/each}
</div>
{#if noteError}
<p class="text-primary mt-4 text-sm">{noteError}</p>
{/if}
</div>
<div class="border-rule h-fit border p-5">
{#if noteForm}
<form onsubmit={saveNote} class="space-y-5">
<p class="kicker">Edit note</p>
<div>
<label for="note-title" class="kicker mb-2 block">Title</label>
<input
id="note-title"
type="text"
bind:value={noteForm.title}
class="field"
required
/>
</div>
<div>
<label for="note-content" class="kicker mb-2 block">Content</label>
<textarea
id="note-content"
bind:value={noteForm.content}
rows={12}
class="field resize-y"
required></textarea>
</div>
{#if noteError}
<p class="text-primary text-sm">{noteError}</p>
{/if}
<div class="flex gap-2">
<button
type="submit"
disabled={noteBusy}
class="btn-primary flex-1"
>
{noteBusy ? "Saving..." : "Save changes"}
</button>
<button type="button" onclick={resetNoteForm} class="btn-ghost">
Cancel
</button>
</div>
</form>
{:else}
<p class="kicker">Note editor</p>
<p class="text-muted mt-4 text-sm">
Select a note and choose Edit to update its title or content.
</p>
{/if}
</div>
</div>
{/if}
{/if}
</div>