mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 15:43:58 +00:00
added admin dashboard
This commit is contained in:
parent
35b0d8020d
commit
203dccda52
7 changed files with 1216 additions and 19 deletions
|
|
@ -12,6 +12,7 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
|
|||
- 👍 **Voting** — upvote or downvote notes and questions.
|
||||
- 🔍 **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.
|
||||
- 🛠️ **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).
|
||||
- 💾 **Persistent storage** — all data is stored in a local SQLite database.
|
||||
|
||||
|
|
@ -109,6 +110,7 @@ src/
|
|||
│ ├── units/[code]/ # Unit pages
|
||||
│ ├── search/ # Search page
|
||||
│ ├── auth/login/ # Sign in page
|
||||
│ ├── admin/ # Admin dashboard
|
||||
│ └── api/+server.ts # JSON API endpoint
|
||||
└── app.html
|
||||
```
|
||||
|
|
@ -124,7 +126,7 @@ src/
|
|||
|
||||
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
|
||||
- `topics` — CS and maths topics
|
||||
- `notes` — shared study notes
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
<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 { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
let auth = $state(false);
|
||||
let admin = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
initAuth();
|
||||
const unsub = isAuthenticated.subscribe((v) => (auth = v));
|
||||
return () => unsub();
|
||||
const unsubAuth = isAuthenticated.subscribe((v) => (auth = v));
|
||||
const unsubUser = currentUser.subscribe((u) => (admin = u?.role === "admin"));
|
||||
return () => {
|
||||
unsubAuth();
|
||||
unsubUser();
|
||||
};
|
||||
});
|
||||
|
||||
const path = $derived(page.url.pathname);
|
||||
const onNotes = $derived(path === "/notes" || path.startsWith("/notes/"));
|
||||
const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/"));
|
||||
const onAdmin = $derived(path === "/admin" || path.startsWith("/admin/"));
|
||||
</script>
|
||||
|
||||
<header class="border-rule border-b bg-white">
|
||||
|
|
@ -27,6 +33,9 @@
|
|||
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}"
|
||||
>Questions</a
|
||||
>
|
||||
{#if admin}
|
||||
<a href="/admin" class="nav-link {onAdmin ? 'nav-link-active' : ''}">Admin</a>
|
||||
{/if}
|
||||
{#if auth}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -68,6 +77,11 @@
|
|||
<a href="/questions" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
|
||||
>Questions</a
|
||||
>
|
||||
{#if admin}
|
||||
<a href="/admin" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
|
||||
>Admin</a
|
||||
>
|
||||
{/if}
|
||||
{#if auth}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -71,23 +71,29 @@ function normalizeEmail(email: string): string {
|
|||
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 = ?",
|
||||
"SELECT id AS _id, email, name, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
|
||||
)
|
||||
.get(token) as Record<string, any> | undefined;
|
||||
if (!user) throw new Error("Not authenticated");
|
||||
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> {
|
||||
return { ...row, solved: !!row.solved };
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
|
||||
function issueSession(db: Db, user: { _id: string; name: string }) {
|
||||
function issueSession(db: Db, user: { _id: string; name: string; role?: string }) {
|
||||
const token = generateToken();
|
||||
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") {
|
||||
|
|
@ -171,22 +177,27 @@ function authSignup(db: Db, args: { email: string; code: string; password: strin
|
|||
|
||||
const id = newId();
|
||||
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());
|
||||
|
||||
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 }) {
|
||||
const email = normalizeEmail(args.email);
|
||||
const user = db
|
||||
.prepare("SELECT id AS _id, name, passwordHash FROM users WHERE email = ?")
|
||||
.get(email) as { _id: string; name: string; passwordHash: string | null } | undefined;
|
||||
.prepare("SELECT id AS _id, name, role, passwordHash FROM users WHERE email = ?")
|
||||
.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");
|
||||
|
||||
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 }) {
|
||||
|
|
@ -203,23 +214,23 @@ function authResetPassword(db: Db, args: { email: string; code: string; password
|
|||
const password = validatePassword(args.password);
|
||||
verifyAndConsumeCode(db, email, args.code);
|
||||
|
||||
const user = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as
|
||||
| { _id: string; name: string }
|
||||
| undefined;
|
||||
const user = db
|
||||
.prepare("SELECT id AS _id, name, role FROM users WHERE email = ?")
|
||||
.get(email) as { _id: string; name: string; role: string } | undefined;
|
||||
if (!user) throw new Error("No account found for this email");
|
||||
|
||||
db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run(
|
||||
hashPassword(password),
|
||||
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 }) {
|
||||
return (
|
||||
db
|
||||
.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
|
||||
);
|
||||
|
|
@ -563,6 +574,314 @@ function getQuestionWithDetails(db: Db, args: { id: string }) {
|
|||
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 ----
|
||||
|
||||
type Handler = (db: Db, args: any) => any;
|
||||
|
|
@ -597,6 +916,18 @@ const handlers: Record<string, Handler> = {
|
|||
"votes:cast": votesCast,
|
||||
"details:getNoteWithDetails": getNoteWithDetails,
|
||||
"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> {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ function createSchema(database: DatabaseSync) {
|
|||
name TEXT NOT NULL,
|
||||
sessionToken TEXT,
|
||||
passwordHash TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
createdAt INTEGER NOT NULL
|
||||
);
|
||||
|
||||
|
|
@ -210,6 +211,9 @@ function migrate(database: DatabaseSync) {
|
|||
if (!columns.some((c) => c.name === "passwordHash")) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -31,13 +31,17 @@ export function initAuth(): Promise<void> {
|
|||
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 }));
|
||||
currentUser.set({
|
||||
_id: result.userId,
|
||||
email: email.toLowerCase(),
|
||||
name: result.name,
|
||||
sessionToken: result.token,
|
||||
role: (result.role ?? "user") as UserDoc["role"],
|
||||
_creationTime: Date.now(),
|
||||
} as UserDoc);
|
||||
isAuthenticated.set(true);
|
||||
|
|
@ -67,6 +71,15 @@ export async function resetPassword(email: string, code: string, password: strin
|
|||
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 {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type UserDoc = Doc<"users"> & {
|
|||
email: string;
|
||||
name: string;
|
||||
sessionToken?: string;
|
||||
role: "user" | "admin";
|
||||
};
|
||||
|
||||
export type TopicDoc = Doc<"topics"> & {
|
||||
|
|
|
|||
832
src/routes/admin/+page.svelte
Normal file
832
src/routes/admin/+page.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue