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.
|
- 👍 **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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"> & {
|
||||||
|
|
|
||||||
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