mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
929 lines
24 KiB
Svelte
929 lines
24 KiB
Svelte
<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;
|
|
unitCode2?: 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: "",
|
|
code2: "",
|
|
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,
|
|
code2: unit.code2 ?? "",
|
|
name: unit.name,
|
|
description: unit.description ?? "",
|
|
};
|
|
unitError = "";
|
|
unitSuccess = "";
|
|
}
|
|
|
|
function resetUnitForm() {
|
|
unitForm = { id: "", code: "", code2: "", 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,
|
|
code2: unitForm.code2,
|
|
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 — DSEC 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 flex-col gap-4 border-b pb-6 sm:flex-row sm:items-end sm:justify-between"
|
|
>
|
|
<div>
|
|
<p class="kicker">Admin</p>
|
|
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">Dashboard</h1>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2" role="tablist" aria-label="Admin sections">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === "overview"}
|
|
class="chip {tab === 'overview' ? 'chip-active' : ''}"
|
|
onclick={() => (tab = "overview")}
|
|
>
|
|
Overview
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === "units"}
|
|
class="chip {tab === 'units' ? 'chip-active' : ''}"
|
|
onclick={() => (tab = "units")}
|
|
>
|
|
Units
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === "accounts"}
|
|
class="chip {tab === 'accounts' ? 'chip-active' : ''}"
|
|
onclick={() => (tab = "accounts")}
|
|
>
|
|
Accounts
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === "notes"}
|
|
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-18 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}{unit.code2 ? ` / ${unit.code2}` : ""}
|
|
</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 sticky top-5 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-code2" class="kicker mb-2 block"
|
|
>Second code (optional)</label
|
|
>
|
|
<input
|
|
id="unit-code2"
|
|
type="text"
|
|
bind:value={unitForm.code2}
|
|
placeholder="e.g., SIT103"
|
|
class="field"
|
|
/>
|
|
</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 sticky top-5 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.unitCode +
|
|
(note.unitCode2 ? ` / ${note.unitCode2}` : "")
|
|
: "—"} · {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>
|