feat: quiz

This commit is contained in:
gosangam
2026-10-15 19:40:28 +05:30
parent 3f3fa9a720
commit 111485a43e
20 changed files with 2178 additions and 224 deletions
+38 -29
View File
@@ -13,6 +13,7 @@
*/
import { useEffect, useState } from "react";
import { Can } from "@/components/staff-context";
import { toast } from "sonner";
import { broadcasts, type Broadcast } from "@/lib/api";
import { compressImage } from "@/lib/compress-image";
@@ -43,7 +44,9 @@ export default function BroadcastsPage() {
</p>
</header>
<Composer onDone={refresh} />
<Can perm="broadcasts:write">
<Composer onDone={refresh} />
</Can>
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">Recent</h2>
@@ -212,14 +215,16 @@ function Composer({ onDone }: { onDone: () => void }) {
>
Save draft
</button>
<button
type="button"
disabled={busy || !title.trim() || !body.trim()}
onClick={() => submit(true)}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Publish now
</button>
<Can perm="broadcasts:publish">
<button
type="button"
disabled={busy || !title.trim() || !body.trim()}
onClick={() => submit(true)}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Publish now
</button>
</Can>
</div>
</section>
);
@@ -268,33 +273,37 @@ function BroadcastRow({
</div>
<div className="flex shrink-0 gap-2">
{!live && (
<Can perm="broadcasts:publish">
{!live && (
<button
type="button"
disabled={busy}
onClick={() =>
window.confirm("Publish now? This cannot be recalled.") &&
act(() => broadcasts.publish(item.id), "Published")
}
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Publish
</button>
)}
</Can>
<Can perm="broadcasts:write">
<button
type="button"
disabled={busy}
onClick={() =>
window.confirm("Publish now? This cannot be recalled.") &&
act(() => broadcasts.publish(item.id), "Published")
window.confirm(
live
? "Delete this? Anyone who hasn't seen it never will."
: "Delete this draft?",
) && act(() => broadcasts.remove(item.id), "Deleted")
}
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
className="rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Publish
Delete
</button>
)}
<button
type="button"
disabled={busy}
onClick={() =>
window.confirm(
live
? "Delete this? Anyone who hasn't seen it never will."
: "Delete this draft?",
) && act(() => broadcasts.remove(item.id), "Deleted")
}
className="rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Delete
</button>
</Can>
</div>
</li>
);
+33 -19
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { Can } from "@/components/staff-context";
import { toast } from "sonner";
import { api, AdminError, type AdminFeedback } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
@@ -40,7 +41,8 @@ export default function FeedbackPage() {
if (
await confirm({
title: "Acknowledge this feedback?",
message: "The author will be emailed that we've seen it and started work.",
message:
"The author will be emailed that we've seen it and started work.",
confirmLabel: "Acknowledge",
})
) {
@@ -52,7 +54,8 @@ export default function FeedbackPage() {
if (
await confirm({
title: "Mark resolved?",
message: "The author will be emailed a thank-you. This can't be undone.",
message:
"The author will be emailed a thank-you. This can't be undone.",
confirmLabel: "Mark resolved",
})
) {
@@ -132,27 +135,35 @@ export default function FeedbackPage() {
<TD className="whitespace-nowrap">{fmtDate(f.created_at)}</TD>
<TD className="text-right">
<div className="flex flex-wrap justify-end gap-2">
{!f.resolved_at && !f.acknowledged_at ? (
<Button
size="sm"
variant="ghost"
onClick={() => acknowledge(f)}
>
Acknowledge
</Button>
) : null}
{!f.resolved_at ? (
<Can perm="feedback:resolve">
{!f.resolved_at && !f.acknowledged_at ? (
<Button
size="sm"
variant="ghost"
onClick={() => acknowledge(f)}
>
Acknowledge
</Button>
) : null}
{!f.resolved_at ? (
<Button
size="sm"
variant="outline"
onClick={() => resolve(f)}
>
Resolve
</Button>
) : null}
</Can>
<Can perm="feedback:reply">
<Button
size="sm"
variant="outline"
onClick={() => resolve(f)}
onClick={() => setReply(f)}
>
Resolve
{f.replied_at ? "Reply again" : "Reply"}
</Button>
) : null}
<Button size="sm" variant="outline" onClick={() => setReply(f)}>
{f.replied_at ? "Reply again" : "Reply"}
</Button>
</Can>
</div>
</TD>
</TR>
@@ -206,7 +217,10 @@ function ReplyModal({
if (!feedback || message.trim() === "") return;
setBusy(true);
try {
const { data } = (await api.replyFeedback(feedback.id, message.trim())) as {
const { data } = (await api.replyFeedback(
feedback.id,
message.trim(),
)) as {
data: { replied: boolean; emailed: boolean };
};
toast.success(
+117 -38
View File
@@ -4,7 +4,9 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
ClipboardCheck,
Flag,
ShieldCheck,
Gift,
Megaphone,
Quote,
@@ -16,22 +18,57 @@ import {
MessageSquare,
Users,
} from "lucide-react";
import { api, type Me } from "@/lib/api";
import { api, staff, MANAGE_STAFF, type Me, type StaffMe } from "@/lib/api";
import { RequirePermission, StaffProvider } from "@/components/staff-context";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ConfirmProvider } from "@/components/confirm";
// Each entry names the permission that unlocks it, so the sidebar only shows
// what the signed-in role can actually use.
const NAV = [
{ href: "/", label: "Overview", icon: LayoutDashboard },
{ href: "/users", label: "Users", icon: Users },
{ href: "/posts", label: "Posts", icon: LayoutGrid },
{ href: "/collections", label: "Collections", icon: FolderClosed },
{ href: "/short-urls", label: "Short URLs", icon: Link2 },
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
{ href: "/reports", label: "Reports", icon: Flag },
{ href: "/broadcasts", label: "Announcements", icon: Megaphone },
{ href: "/reward-gifts", label: "Gifts", icon: Gift },
{ href: "/vani", label: "Vāṇī of the Day", icon: Quote },
{ href: "/", label: "Overview", icon: LayoutDashboard, perm: "stats:read" },
{ href: "/users", label: "Users", icon: Users, perm: "users:read" },
{ href: "/posts", label: "Posts", icon: LayoutGrid, perm: "posts:moderate" },
{
href: "/collections",
label: "Collections",
icon: FolderClosed,
perm: "collections:read",
},
{
href: "/short-urls",
label: "Short URLs",
icon: Link2,
perm: "short_urls:read",
},
{
href: "/feedback",
label: "Feedback",
icon: MessageSquare,
perm: "feedback:read",
},
{ href: "/reports", label: "Reports", icon: Flag, perm: "reports:read" },
{
href: "/broadcasts",
label: "Announcements",
icon: Megaphone,
perm: "broadcasts:read",
},
{ href: "/reward-gifts", label: "Gifts", icon: Gift, perm: "rewards:read" },
{ href: "/vani", label: "Vāṇī of the Day", icon: Quote, perm: "vani:read" },
{
href: "/quizzes",
label: "Quizzes",
icon: ClipboardCheck,
perm: "quizzes:read",
},
{
href: "/staff",
label: "Staff & roles",
icon: ShieldCheck,
perm: MANAGE_STAFF,
},
];
export default function DashboardLayout({
@@ -42,22 +79,24 @@ export default function DashboardLayout({
const router = useRouter();
const pathname = usePathname();
const [me, setMe] = useState<Me | null>(null);
const [staffMe, setStaffMe] = useState<StaffMe | null>(null);
const [checked, setChecked] = useState(false);
// Admin gate: only an authenticated admin may see the dashboard. Anyone
// else is bounced to /login. The BFF independently enforces this on every
// /admin/* call, so this is UX, not the security boundary.
// Staff gate: only an account holding an admin role, and not blocked from
// the panel, may see the dashboard. /admin/me answers both at once — it 403s
// otherwise. The BFF enforces this on every /admin/* call regardless, so
// this is UX, not the security boundary.
useEffect(() => {
let cancelled = false;
api
.me()
.then((u) => {
Promise.all([api.me(), staff.me()])
.then(([u, s]) => {
if (cancelled) return;
if (!u || u.role !== "admin") {
if (!u || !s) {
router.replace("/login");
return;
}
setMe(u);
setStaffMe(s);
setChecked(true);
})
.catch(() => {
@@ -68,12 +107,25 @@ export default function DashboardLayout({
};
}, [router]);
// A role without stats:read has no Overview — send them to the first
// section they can use instead of a page of permission errors.
useEffect(() => {
if (
!staffMe ||
pathname !== "/" ||
staffMe.permissions.includes("stats:read")
)
return;
const first = NAV.find((n) => staffMe.permissions.includes(n.perm));
if (first) router.replace(first.href);
}, [staffMe, pathname, router]);
async function signOut() {
await api.logout().catch(() => {});
router.replace("/login");
}
if (!checked || !me) {
if (!checked || !me || !staffMe) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Loading…
@@ -91,29 +143,35 @@ export default function DashboardLayout({
<p className="mt-0.5 text-sm font-semibold text-foreground">Admin</p>
</div>
<nav className="flex-1 space-y-1 px-3">
{NAV.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{label}
</Link>
);
})}
{NAV.filter((n) => staffMe.permissions.includes(n.perm)).map(
({ href, label, icon: Icon }) => {
const active =
pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{label}
</Link>
);
},
)}
</nav>
<div className="border-t border-border px-3 py-3">
<div className="px-3 pb-2 text-xs text-muted-foreground">
<p className="truncate font-medium text-foreground">{me.name}</p>
<p className="truncate">@{me.handle}</p>
<p className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{staffMe.role_name}
</p>
</div>
<Button
variant="ghost"
@@ -129,7 +187,28 @@ export default function DashboardLayout({
<main className="flex-1 overflow-x-hidden px-6 py-6 lg:px-10">
<div className="mx-auto max-w-6xl">
<ConfirmProvider>{children}</ConfirmProvider>
<StaffProvider value={staffMe}>
<ConfirmProvider>
{/* The section's own permission, so a URL typed straight in
explains itself instead of failing call by call. */}
{(() => {
const section =
NAV.find(
(n) =>
n.href !== "/" &&
(pathname === n.href ||
pathname.startsWith(n.href + "/")),
) ?? (pathname === "/" ? NAV[0] : undefined);
return section ? (
<RequirePermission perm={section.perm}>
{children}
</RequirePermission>
) : (
children
);
})()}
</ConfirmProvider>
</StaffProvider>
</div>
</main>
</div>
+1 -1
View File
@@ -54,7 +54,7 @@ export default function OverviewPage() {
<h2 className="text-lg font-semibold text-foreground">
Feedback by category
</h2>
{stats.feedback_by_category.length === 0 ? (
{!stats.feedback_by_category?.length ? (
<p className="text-sm text-muted-foreground">No feedback yet.</p>
) : (
<div className="max-w-md divide-y divide-border rounded-xl border border-border bg-card">
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { quizzes, AdminError, type Quiz } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { useConfirm } from "@/components/confirm";
import { Can, useStaff } from "@/components/staff-context";
import { cn } from "@/lib/utils";
import { QuizEditor } from "../quiz-editor";
import { KIND_LABEL } from "../labels";
import { Participants, Responses } from "./results";
type Tab = "edit" | "participants" | "responses";
export default function QuizDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const confirm = useConfirm();
const { can } = useStaff();
const [quiz, setQuiz] = useState<Quiz | null>(null);
const [tab, setTab] = useState<Tab>("edit");
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
quizzes
.get(id)
.then(setQuiz)
.catch((err) =>
toast.error(
err instanceof AdminError ? err.message : "Could not load the quiz",
),
);
}, [id]);
useEffect(() => {
load();
}, [load]);
if (!quiz) return <p className="text-sm text-muted-foreground">Loading…</p>;
const togglePublish = async () => {
const on = !quiz.published_at;
if (
!on &&
!(await confirm({
title: "Unpublish this quiz?",
message:
"It disappears from the app. Results so far are kept, and you can publish it again.",
confirmLabel: "Unpublish",
}))
) {
return;
}
setBusy(true);
try {
setQuiz(await quizzes.setPublished(quiz.id, on));
toast.success(on ? "Published" : "Unpublished");
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "That didn't work");
} finally {
setBusy(false);
}
};
const remove = async () => {
if (
!(await confirm({
title: `Delete “${quiz.title}”?`,
message:
"It disappears from the app. Participants' results are kept on record.",
confirmLabel: "Delete",
destructive: true,
}))
) {
return;
}
try {
await quizzes.remove(quiz.id);
toast.success("Quiz deleted");
router.replace("/quizzes");
} catch (err) {
toast.error(
err instanceof AdminError ? err.message : "Could not delete the quiz",
);
}
};
const tabs: { key: Tab; label: string; show: boolean }[] = [
{
key: "edit",
label: can("quizzes:write") ? "Edit" : "Questions",
show: true,
},
{
key: "participants",
label: `Participants (${quiz.attempt_count ?? 0})`,
show: can("quizzes:results"),
},
{ key: "responses", label: "Responses", show: can("quizzes:results") },
];
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<Link
href="/quizzes"
className="text-xs text-muted-foreground hover:text-foreground"
>
← Quizzes
</Link>
<h1 className="mt-1 text-xl font-semibold text-foreground">
{quiz.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{KIND_LABEL[quiz.target_kind]}
{quiz.target_title && ` · ${quiz.target_title}`}
{quiz.target_path && (
<span className="ml-1 font-mono text-xs">
({quiz.target_path})
</span>
)}
</p>
</div>
<div className="flex gap-2">
<Can perm="quizzes:write">
<Button
variant={quiz.published_at ? "outline" : "primary"}
disabled={busy}
onClick={() => void togglePublish()}
>
{quiz.published_at ? "Unpublish" : "Publish"}
</Button>
</Can>
<Can perm="quizzes:delete">
<Button variant="ghost" onClick={() => void remove()}>
Delete
</Button>
</Can>
</div>
</header>
<div className="flex gap-1 border-b border-border">
{tabs
.filter((t) => t.show)
.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={cn(
"-mb-px border-b-2 px-3 py-2 text-sm transition-colors",
tab === t.key
? "border-primary font-medium text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
{tab === "edit" && (
<QuizEditor
key={quiz.updated_at}
initial={quiz}
onSaved={setQuiz}
readOnly={!can("quizzes:write")}
/>
)}
{tab === "participants" && <Participants quizId={quiz.id} />}
{tab === "responses" && <Responses quizId={quiz.id} />}
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
"use client";
/**
* Who took a quiz, and how each question went.
*
* Response times are measured by the BFF — from the moment a question was
* served to the moment its answer arrived — so they can't be massaged by the
* browser. They include any time the person spent away from the screen.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
quizzes,
AdminError,
type QuizParticipant,
type QuizQuestionStat,
type QuizResult,
} from "@/lib/api";
import { Avatar } from "@/components/ui/avatar";
import { Modal } from "@/components/ui/modal";
import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { RichText } from "@/lib/rich-text";
import { cn, fmtDate } from "@/lib/utils";
import { fmtMs } from "../labels";
const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback;
export function Participants({ quizId }: { quizId: string }) {
const [rows, setRows] = useState<QuizParticipant[] | null>(null);
const [open, setOpen] = useState<QuizParticipant | null>(null);
const [detail, setDetail] = useState<QuizResult | null>(null);
useEffect(() => {
quizzes
.participants(quizId)
.then(setRows)
.catch((err) => {
toast.error(errMsg(err, "Could not load participants"));
setRows([]);
});
}, [quizId]);
const view = (p: QuizParticipant) => {
setOpen(p);
setDetail(null);
quizzes
.participant(quizId, p.user_id)
.then(setDetail)
.catch((err) => toast.error(errMsg(err, "Could not load their answers")));
};
if (rows === null)
return <p className="text-sm text-muted-foreground">Loading…</p>;
if (rows.length === 0)
return (
<p className="text-sm text-muted-foreground">
Nobody has taken this quiz yet.
</p>
);
return (
<>
<Table>
<THead>
<TR>
<TH>Person</TH>
<TH>Score</TH>
<TH>Progress</TH>
<TH>Time</TH>
<TH>Started</TH>
</TR>
</THead>
<tbody>
{rows.map((p) => (
<TR key={p.user_id}>
<TD>
<button
type="button"
onClick={() => view(p)}
className="flex items-center gap-3 text-left hover:underline"
>
<Avatar
src={p.avatar_url ?? null}
name={p.name}
className="size-8"
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{p.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
@{p.handle}
</span>
</span>
</button>
</TD>
<TD>
{p.completed_at ? (
<span className="font-medium">
{p.score} / {p.total}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</TD>
<TD>
{p.completed_at ? (
<span className="text-xs text-primary">Finished</span>
) : (
<span className="text-xs text-muted-foreground">
{p.answered} of {p.total} answered
</span>
)}
</TD>
<TD className="tabular-nums">{fmtMs(p.total_ms)}</TD>
<TD className="text-xs text-muted-foreground">
{fmtDate(p.started_at)}
</TD>
</TR>
))}
</tbody>
</Table>
<Modal
open={open !== null}
onClose={() => setOpen(null)}
title={open ? `${open.name}'s answers` : ""}
>
{!detail ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
<p className="text-sm text-muted-foreground">
{detail.completed_at
? `Scored ${detail.score} of ${detail.total}`
: "Still in progress"}{" "}
· {fmtMs(detail.total_ms)} in total
</p>
{detail.items.map((it, i) => (
<div key={it.id ?? i} className="space-y-1.5">
<div className="flex items-start justify-between gap-3 text-sm">
<p className="font-medium">
{i + 1}. <RichText text={it.prompt} />
</p>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{fmtMs(it.response_ms)}
</span>
</div>
<ul className="space-y-1">
{it.options.map((o) => {
const chosen = !!o.id && it.chosen_option_ids.includes(o.id);
return (
<li
key={o.id}
className={cn(
"rounded-md px-3 py-1.5 text-sm",
o.is_correct
? "bg-primary font-medium text-primary-foreground"
: chosen
? "bg-destructive/10 text-destructive"
: "bg-muted/40",
)}
>
<RichText text={o.body} />
{chosen && (
<span className="ml-2 text-xs">← their answer</span>
)}
</li>
);
})}
</ul>
{it.chosen_option_ids.length === 0 && (
<p className="text-xs text-muted-foreground">
Not answered yet.
</p>
)}
</div>
))}
</div>
)}
</Modal>
</>
);
}
export function Responses({ quizId }: { quizId: string }) {
const [stats, setStats] = useState<QuizQuestionStat[] | null>(null);
useEffect(() => {
quizzes
.responses(quizId)
.then(setStats)
.catch((err) => {
toast.error(errMsg(err, "Could not load responses"));
setStats([]);
});
}, [quizId]);
if (stats === null)
return <p className="text-sm text-muted-foreground">Loading…</p>;
return (
<ol className="space-y-4">
{stats.map((q, i) => {
const pct = q.answered ? Math.round((q.correct / q.answered) * 100) : 0;
return (
<li key={q.id} className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-4">
<p className="text-sm font-medium">
{i + 1}. <RichText text={q.prompt} />
{q.multi && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Multiple answers
</span>
)}
</p>
<div className="shrink-0 text-right text-xs text-muted-foreground">
<p>
<span className="font-medium text-foreground">{pct}%</span>{" "}
correct · {q.answered} answered
</p>
<p className="tabular-nums">
avg {fmtMs(q.avg_ms)} · median {fmtMs(q.median_ms)}
</p>
</div>
</div>
<ul className="mt-3 space-y-1.5">
{q.options.map((o) => {
const share = q.answered ? (o.picks / q.answered) * 100 : 0;
return (
<li
key={o.id}
className="relative overflow-hidden rounded-md border border-border"
>
<div
className={cn(
"absolute inset-y-0 left-0",
o.is_correct ? "bg-primary/15" : "bg-muted",
)}
style={{ width: `${share}%` }}
aria-hidden
/>
<div className="relative flex items-center justify-between gap-3 px-3 py-1.5 text-sm">
<span
className={cn(
o.is_correct && "font-medium text-primary",
)}
>
<RichText text={o.body} />
{o.is_correct && (
<span className="ml-2 text-xs">✓ correct</span>
)}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{o.picks} · {Math.round(share)}%
</span>
</div>
</li>
);
})}
</ul>
</li>
);
})}
</ol>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Quiz } from "@/lib/api";
export const KIND_LABEL: Record<Quiz["target_kind"], string> = {
chapter: "Book chapter",
lecture: "Lecture",
conversation: "Conversation",
standalone: "Stand-alone",
};
/** "1m 12s" / "8.4s" — response times read better than raw milliseconds. */
export function fmtMs(ms: number | null | undefined): string {
if (ms == null) return "—";
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = Math.round((ms % 60_000) / 1000);
return `${m}m ${s}s`;
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { QuizEditor } from "../quiz-editor";
import { RequirePermission } from "@/components/staff-context";
export default function NewQuizPage() {
const router = useRouter();
return (
<RequirePermission perm="quizzes:write">
<div className="space-y-6">
<h1 className="text-xl font-semibold text-foreground">New quiz</h1>
<QuizEditor
initial={null}
onSaved={(q) => router.replace(`/quizzes/${q.id}`)}
/>
</div>
</RequirePermission>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
/**
* /quizzes — every quiz, with how many people have started and finished it.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { quizzes, AdminError, type Quiz } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { Can } from "@/components/staff-context";
import { fmtDate } from "@/lib/utils";
import { KIND_LABEL } from "./labels";
export default function QuizzesPage() {
const [list, setList] = useState<Quiz[] | null>(null);
useEffect(() => {
quizzes
.list()
.then(setList)
.catch((err) => {
toast.error(
err instanceof AdminError ? err.message : "Could not load quizzes",
);
setList([]);
});
}, []);
return (
<div className="space-y-6">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Quizzes</h1>
<p className="mt-1 text-sm text-muted-foreground">
Quizzes on book chapters, lectures and conversations — or on their
own. A published quiz shows a button on its page and appears in the
app&apos;s quiz list.
</p>
</div>
<Can perm="quizzes:write">
<Link href="/quizzes/new">
<Button>New quiz</Button>
</Link>
</Can>
</header>
{list === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : list.length === 0 ? (
<p className="text-sm text-muted-foreground">No quizzes yet.</p>
) : (
<Table>
<THead>
<TR>
<TH>Quiz</TH>
<TH>Attached to</TH>
<TH>Questions</TH>
<TH>Taken</TH>
<TH>Status</TH>
</TR>
</THead>
<tbody>
{list.map((q) => (
<TR key={q.id}>
<TD>
<Link
href={`/quizzes/${q.id}`}
className="font-medium text-foreground hover:underline"
>
{q.title}
</Link>
<p className="text-xs text-muted-foreground">
Updated {fmtDate(q.updated_at)}
</p>
</TD>
<TD>
<p className="text-sm">{KIND_LABEL[q.target_kind]}</p>
{q.target_title && (
<p className="truncate text-xs text-muted-foreground">
{q.target_title}
</p>
)}
</TD>
<TD>{q.question_count}</TD>
<TD>
{q.completed_count ?? 0} finished
<span className="text-muted-foreground">
{" "}
/ {q.attempt_count ?? 0} started
</span>
</TD>
<TD>
{q.published_at ? (
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
Published
</span>
) : (
<span className="text-xs text-muted-foreground">Draft</span>
)}
</TD>
</TR>
))}
</tbody>
</Table>
)}
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
/**
* Create or edit a quiz.
*
* Questions are multiple choice: 2–5 options, with exactly one correct — or,
* for a "multiple answers" question, one or more correct. Prompts,
* options, explanations and the description all take the same formatting
* markers as the rest of the panel (bold, italic, underline, strike, links).
*
* Once anyone has taken a quiz its questions are frozen — their answers point
* at those exact questions and options — so the editor shows them read-only
* and only the title, description and target stay editable.
*/
import { useState } from "react";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
quizzes,
AdminError,
type Quiz,
type QuizDraft,
type QuizQuestion,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RichTextarea } from "@/components/ui/rich-textarea";
import { RichText } from "@/lib/rich-text";
import { cn } from "@/lib/utils";
const MAX_OPTIONS = 5;
const MIN_OPTIONS = 2;
const blankQuestion = (): QuizQuestion => ({
prompt: "",
explanation: "",
multi: false,
options: [
{ body: "", is_correct: true },
{ body: "", is_correct: false },
],
});
/** Mirrors the BFF's rules, so problems show before a round trip. */
function problemWith(q: QuizQuestion): string | null {
if (!q.prompt.trim()) return "needs a prompt";
if (q.options.length < MIN_OPTIONS || q.options.length > MAX_OPTIONS)
return `needs ${MIN_OPTIONS}–${MAX_OPTIONS} options`;
if (q.options.some((o) => !o.body.trim())) return "has an empty option";
const correct = q.options.filter((o) => o.is_correct).length;
if (q.multi && correct === 0) return "needs at least one correct option";
if (!q.multi && correct !== 1) return "needs exactly one correct option";
return null;
}
export function QuizEditor({
initial,
onSaved,
readOnly = false,
}: {
initial: Quiz | null;
onSaved: (q: Quiz) => void;
/** For staff who may view quizzes but not change them. */
readOnly?: boolean;
}) {
const taken = (initial?.attempt_count ?? 0) > 0;
// Questions render frozen either way; only `taken` explains why.
const locked = taken || readOnly;
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [targetUrl, setTargetUrl] = useState(initial?.target_path ?? "");
const [targetTitle, setTargetTitle] = useState(initial?.target_title ?? "");
const [questions, setQuestions] = useState<QuizQuestion[]>(
initial?.questions?.length ? initial.questions : [blankQuestion()],
);
const [saving, setSaving] = useState(false);
const editQ = (i: number, patch: Partial<QuizQuestion>) =>
setQuestions((qs) => qs.map((q, j) => (j === i ? { ...q, ...patch } : q)));
const editOption = (qi: number, oi: number, body: string) =>
editQ(qi, {
options: questions[qi].options.map((o, j) =>
j === oi ? { ...o, body } : o,
),
});
// Single answer: picking one clears the rest. Multiple answers: toggles.
const markCorrect = (qi: number, oi: number) =>
editQ(qi, {
options: questions[qi].options.map((o, j) =>
questions[qi].multi
? j === oi
? { ...o, is_correct: !o.is_correct }
: o
: { ...o, is_correct: j === oi },
),
});
// Back to a single answer keeps only the first correct option.
const setMulti = (qi: number, multi: boolean) => {
const opts = questions[qi].options;
const first = opts.findIndex((o) => o.is_correct);
editQ(qi, {
multi,
options: multi
? opts
: opts.map((o, j) => ({ ...o, is_correct: j === Math.max(first, 0) })),
});
};
const removeOption = (qi: number, oi: number) => {
const opts = questions[qi].options.filter((_, j) => j !== oi);
// Removing the last correct option would leave none marked; promote the
// first.
if (!opts.some((o) => o.is_correct) && opts[0])
opts[0] = { ...opts[0], is_correct: true };
editQ(qi, { options: opts });
};
const move = (i: number, by: number) =>
setQuestions((qs) => {
const j = i + by;
if (j < 0 || j >= qs.length) return qs;
const next = [...qs];
[next[i], next[j]] = [next[j], next[i]];
return next;
});
const save = async () => {
if (!title.trim()) return toast.error("Give the quiz a title");
if (!locked) {
const bad = questions.map(problemWith).findIndex(Boolean);
if (bad >= 0)
return toast.error(
`Question ${bad + 1} ${problemWith(questions[bad])}`,
);
}
const draft: QuizDraft = {
title: title.trim(),
description,
target_url: targetUrl.trim(),
target_title: targetUrl.trim() ? targetTitle.trim() : "",
...(locked ? {} : { questions }),
};
setSaving(true);
try {
const saved = initial
? await quizzes.update(initial.id, draft)
: await quizzes.create(draft);
toast.success(
initial ? "Quiz saved" : "Quiz created — publish it when it's ready",
);
onSaved(saved);
} catch (err) {
toast.error(
err instanceof AdminError ? err.message : "Could not save the quiz",
);
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<fieldset
disabled={readOnly}
className="space-y-3 rounded-xl border border-border p-4 disabled:opacity-80"
>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Quiz title"
maxLength={200}
/>
<RichTextarea
value={description}
onValueChange={setDescription}
rows={3}
maxLength={2000}
placeholder="Introduction shown before the first question (optional)"
/>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Input
value={targetUrl}
onChange={(e) => setTargetUrl(e.target.value)}
placeholder="Link to a chapter, lecture or conversation"
/>
<p className="mt-1 text-xs text-muted-foreground">
Paste the page&apos;s link, e.g. /books/bg/2 or
/transcriptions/720720sb-london. Leave empty for a stand-alone
quiz.
</p>
</div>
{targetUrl.trim() && (
<div>
<Input
value={targetTitle}
onChange={(e) => setTargetTitle(e.target.value)}
placeholder="Content title (filled in automatically)"
/>
<p className="mt-1 text-xs text-muted-foreground">
Override only if the automatic title reads badly.
</p>
</div>
)}
</div>
</fieldset>
{taken && !readOnly && (
<p className="rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
{initial?.attempt_count}{" "}
{initial?.attempt_count === 1 ? "person has" : "people have"} taken
this quiz, so its questions can no longer change. You can still edit
the title, introduction and link.
</p>
)}
<ol className="space-y-4">
{questions.map((q, qi) => (
<li
key={q.id ?? `new-${qi}`}
className="rounded-xl border border-border p-4"
>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-medium text-foreground">
Question {qi + 1}
</p>
{!locked && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
disabled={qi === 0}
onClick={() => move(qi, -1)}
aria-label="Move up"
>
<ArrowUp className="size-4" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={qi === questions.length - 1}
onClick={() => move(qi, 1)}
aria-label="Move down"
>
<ArrowDown className="size-4" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={questions.length === 1}
onClick={() =>
setQuestions((qs) => qs.filter((_, j) => j !== qi))
}
aria-label="Delete question"
>
<Trash2 className="size-4" />
</Button>
</div>
)}
</div>
{locked ? (
<div className="space-y-2 text-sm">
<p className="text-foreground">
<RichText text={q.prompt} />
</p>
{q.multi && (
<p className="text-xs text-muted-foreground">
Multiple answers
</p>
)}
<ul className="space-y-1">
{q.options.map((o, oi) => (
<li
key={oi}
className={cn(
"rounded-md px-3 py-1.5",
o.is_correct
? "bg-primary font-medium text-primary-foreground"
: "bg-muted/40",
)}
>
<RichText text={o.body} />
</li>
))}
</ul>
</div>
) : (
<div className="space-y-3">
<RichTextarea
value={q.prompt}
onValueChange={(v) => editQ(qi, { prompt: v })}
rows={2}
maxLength={2000}
placeholder="Question"
/>
<label className="flex w-fit items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={q.multi}
onChange={(e) => setMulti(qi, e.target.checked)}
className="accent-primary"
/>
Multiple correct answers
<span className="text-xs text-muted-foreground">
— users select all that apply
</span>
</label>
<fieldset className="space-y-2">
<legend className="mb-1 text-xs text-muted-foreground">
{q.multi
? "Options — tick every correct one"
: "Options — select the correct one"}
</legend>
{q.options.map((o, oi) => (
<div key={oi} className="flex items-start gap-2">
<input
type={q.multi ? "checkbox" : "radio"}
name={`correct-${qi}`}
checked={o.is_correct}
onChange={() => markCorrect(qi, oi)}
className="mt-3 accent-primary"
aria-label={`Option ${oi + 1} is correct`}
/>
<RichTextarea
value={o.body}
onValueChange={(v) => editOption(qi, oi, v)}
rows={1}
maxLength={500}
placeholder={`Option ${oi + 1}`}
className="flex-1"
/>
<Button
size="sm"
variant="ghost"
className="mt-1"
disabled={q.options.length <= MIN_OPTIONS}
onClick={() => removeOption(qi, oi)}
aria-label={`Remove option ${oi + 1}`}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
{q.options.length < MAX_OPTIONS && (
<Button
size="sm"
variant="ghost"
onClick={() =>
editQ(qi, {
options: [
...q.options,
{ body: "", is_correct: false },
],
})
}
>
<Plus className="size-4" /> Add option
</Button>
)}
</fieldset>
<RichTextarea
value={q.explanation}
onValueChange={(v) => editQ(qi, { explanation: v })}
rows={2}
maxLength={2000}
placeholder="Explanation shown with the answer (optional)"
/>
</div>
)}
</li>
))}
</ol>
<div className="flex items-center justify-between">
{!locked ? (
<Button
variant="outline"
onClick={() => setQuestions((qs) => [...qs, blankQuestion()])}
>
<Plus className="size-4" /> Add question
</Button>
) : (
<span />
)}
{!readOnly && (
<Button onClick={() => void save()} disabled={saving}>
{saving ? "Saving…" : initial ? "Save changes" : "Create quiz"}
</Button>
)}
</div>
</div>
);
}
+34 -15
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { Can } from "@/components/staff-context";
import { toast } from "sonner";
import { api, AdminError, type AdminReport, type ReportType } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
@@ -46,7 +47,11 @@ export default function ReportsPage() {
if (r.type === "post") {
return (
<>
<Button size="sm" variant="outline" onClick={() => run(api.hidePost(r.target_id), "Post hidden.")}>
<Button
size="sm"
variant="outline"
onClick={() => run(api.hidePost(r.target_id), "Post hidden.")}
>
Hide
</Button>
<Button
@@ -56,7 +61,8 @@ export default function ReportsPage() {
if (
await confirm({
title: "Delete this post?",
message: "It will be removed from the app. You can undo this.",
message:
"It will be removed from the app. You can undo this.",
confirmLabel: "Delete post",
destructive: true,
})
@@ -79,7 +85,8 @@ export default function ReportsPage() {
if (
await confirm({
title: "Delete this comment?",
message: "It will be removed from the thread. You can undo this.",
message:
"It will be removed from the thread. You can undo this.",
confirmLabel: "Delete comment",
destructive: true,
})
@@ -100,7 +107,8 @@ export default function ReportsPage() {
if (
await confirm({
title: "Suspend this account?",
message: "They'll be signed out and unable to log in. You can undo this.",
message:
"They'll be signed out and unable to log in. You can undo this.",
confirmLabel: "Suspend account",
destructive: true,
})
@@ -122,7 +130,8 @@ export default function ReportsPage() {
size="sm"
variant="outline"
onClick={() => {
if (r.type === "post") run(api.restorePost(r.target_id), "Post restored.");
if (r.type === "post")
run(api.restorePost(r.target_id), "Post restored.");
else if (r.type === "comment")
run(api.restoreComment(r.target_id), "Comment restored.");
else run(api.unbanUser(r.target_id), "Account restored.");
@@ -134,7 +143,11 @@ export default function ReportsPage() {
}
const targetLabel =
type === "user" ? "Reported account" : type === "comment" ? "Comment" : "Post";
type === "user"
? "Reported account"
: type === "comment"
? "Comment"
: "Post";
return (
<ListShell
@@ -187,7 +200,9 @@ export default function ReportsPage() {
</span>
) : (
<>
<div className="line-clamp-2">{r.target_text || "—"}</div>
<div className="line-clamp-2">
{r.target_text || "—"}
</div>
{r.target_author_handle ? (
<div className="mt-0.5 text-xs text-muted-foreground">
by @{r.target_author_handle}
@@ -231,13 +246,15 @@ export default function ReportsPage() {
) : (
<div className="flex flex-wrap justify-end gap-2">
{r.target_removed ? undoButton(r) : modButton(r)}
<Button
size="sm"
variant="ghost"
onClick={() => setResolveTarget(r)}
>
Resolve
</Button>
<Can perm="reports:resolve">
<Button
size="sm"
variant="ghost"
onClick={() => setResolveTarget(r)}
>
Resolve
</Button>
</Can>
</div>
)}
</TD>
@@ -281,7 +298,9 @@ function ResolveModal({
setNote("");
onDone();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Could not resolve.");
toast.error(
err instanceof AdminError ? err.message : "Could not resolve.",
);
} finally {
setBusy(false);
}
+33 -24
View File
@@ -12,6 +12,7 @@
*/
import { useEffect, useState } from "react";
import { Can } from "@/components/staff-context";
import Image from "next/image";
import { toast } from "sonner";
import { rewardGifts, type GiftableReward } from "@/lib/api";
@@ -48,7 +49,9 @@ export default function RewardGiftsPage() {
: `Sent to ${granted} ${granted === 1 ? "devotee" : "devotees"}`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Could not send the gift");
toast.error(
err instanceof Error ? err.message : "Could not send the gift",
);
} finally {
setSending(null);
}
@@ -81,16 +84,18 @@ export default function RewardGiftsPage() {
Occasional gifts
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Send a sticker to everyone, no streak required. It arrives wrapped
and opens with a celebration.
Send a sticker to everyone, no streak required. It arrives wrapped and
opens with a celebration.
</p>
</header>
<CustomGift
onCreated={(reward) =>
setCatalog((prev) => (prev ? [...prev, reward] : [reward]))
}
/>
<Can perm="rewards:write">
<CustomGift
onCreated={(reward) =>
setCatalog((prev) => (prev ? [...prev, reward] : [reward]))
}
/>
</Can>
{catalog === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
@@ -122,27 +127,31 @@ export default function RewardGiftsPage() {
{r.name}
</p>
<div className="mt-auto w-full space-y-1.5">
<button
type="button"
disabled={sending !== null}
onClick={() => send(r)}
className="w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{sending === r.key ? "Sending…" : "Send to everyone"}
</button>
{/* Seeded rewards have no delete: they would return on the
next seed run, having been taken from the galleries of
everyone who earned them. */}
{r.key.startsWith("custom_") && (
<Can perm="rewards:send">
<button
type="button"
disabled={sending !== null}
onClick={() => remove(r)}
className="w-full rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
onClick={() => send(r)}
className="w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Delete
{sending === r.key ? "Sending…" : "Send to everyone"}
</button>
)}
</Can>
{/* Seeded rewards have no delete: they would return on the
next seed run, having been taken from the galleries of
everyone who earned them. */}
<Can perm="rewards:delete">
{r.key.startsWith("custom_") && (
<button
type="button"
disabled={sending !== null}
onClick={() => remove(r)}
className="w-full rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Delete
</button>
)}
</Can>
</div>
</li>
))}
+53
View File
@@ -0,0 +1,53 @@
"use client";
/**
* /staff — who can use the admin panel, and with what powers. Superadmins only.
*
* Two tabs: the people (grant, change role, block, revoke) and the roles
* (the three system roles, plus custom ones built from the permission
* catalog). Every rule that matters — you can't act on yourself, the last
* active superadmin can't be removed, custom roles can't hold staff
* management — is enforced by the BFF; this page just explains the refusal.
*/
import { useState } from "react";
import { cn } from "@/lib/utils";
import { StaffTab } from "./staff-tab";
import { RolesTab } from "./roles-tab";
export default function StaffPage() {
const [tab, setTab] = useState<"staff" | "roles">("staff");
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold text-foreground">
Staff &amp; roles
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Decide who can use this panel and what each role may do.
</p>
</header>
<div className="flex gap-1 border-b border-border">
{(["staff", "roles"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
"-mb-px border-b-2 px-3 py-2 text-sm capitalize transition-colors",
tab === t
? "border-primary font-medium text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t}
</button>
))}
</div>
{tab === "staff" ? <StaffTab /> : <RolesTab />}
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
/**
* Roles: the three system roles (read-only) and custom ones.
*
* A custom role is any subset of the permission catalog. Staff management
* itself isn't in the catalog, so it can't be granted here — that stays with
* superadmins.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { staff, AdminError, type AdminRole, type Permission } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { useConfirm } from "@/components/confirm";
const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback;
type Draft = {
id?: string;
name: string;
description: string;
permissions: Set<string>;
};
export function RolesTab() {
const confirm = useConfirm();
const [roles, setRoles] = useState<AdminRole[] | null>(null);
const [catalog, setCatalog] = useState<Permission[]>([]);
const [draft, setDraft] = useState<Draft | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const [r, c] = await Promise.all([staff.roles(), staff.catalog()]);
setRoles(r);
setCatalog(c);
} catch (err) {
toast.error(errMsg(err, "Could not load roles"));
setRoles([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// Catalog grouped for the editor and for describing system roles.
const groups = useMemo(() => {
const m = new Map<string, Permission[]>();
for (const p of catalog) m.set(p.group, [...(m.get(p.group) ?? []), p]);
return [...m.entries()];
}, [catalog]);
const labelOf = useMemo(
() => new Map(catalog.map((p) => [p.key, p.label])),
[catalog],
);
const save = async () => {
if (!draft) return;
setSaving(true);
const input = {
name: draft.name.trim(),
description: draft.description.trim(),
permissions: [...draft.permissions],
};
try {
if (draft.id) await staff.updateRole(draft.id, input);
else await staff.createRole(input);
toast.success(draft.id ? "Role updated" : "Role created");
setDraft(null);
await load();
} catch (err) {
toast.error(errMsg(err, "Could not save the role"));
} finally {
setSaving(false);
}
};
const remove = async (r: AdminRole) => {
if (
!(await confirm({
title: `Delete the ${r.name} role?`,
message: "Only roles nobody holds can be deleted.",
confirmLabel: "Delete",
destructive: true,
}))
) {
return;
}
try {
await staff.deleteRole(r.id);
toast.success("Role deleted");
await load();
} catch (err) {
toast.error(errMsg(err, "Could not delete the role"));
}
};
const toggle = (key: string) =>
setDraft((d) => {
if (!d) return d;
const next = new Set(d.permissions);
if (next.has(key)) next.delete(key);
else next.add(key);
return { ...d, permissions: next };
});
if (roles === null)
return <p className="text-sm text-muted-foreground">Loading…</p>;
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
onClick={() =>
setDraft({ name: "", description: "", permissions: new Set() })
}
>
New role
</Button>
</div>
<ul className="space-y-3">
{roles.map((r) => (
<li key={r.id} className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{r.name}{" "}
{r.is_system && (
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
System
</span>
)}
</p>
{r.description && (
<p className="mt-0.5 text-xs text-muted-foreground">
{r.description}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{r.member_count} {r.member_count === 1 ? "member" : "members"}
</p>
</div>
{!r.is_system && (
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
setDraft({
id: r.id,
name: r.name,
description: r.description,
permissions: new Set(r.permissions),
})
}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void remove(r)}
>
Delete
</Button>
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{r.permissions.includes("*") ? (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
Everything, including managing staff
</span>
) : (
r.permissions.map((p) => (
<span
key={p}
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{labelOf.get(p) ?? p}
</span>
))
)}
</div>
</li>
))}
</ul>
<Modal
open={draft !== null}
onClose={() => setDraft(null)}
title={draft?.id ? "Edit role" : "New role"}
>
{draft && (
<div className="space-y-4">
<Input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Role name — e.g. Quiz editor"
maxLength={80}
/>
<Input
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.target.value })
}
placeholder="What is this role for? (optional)"
/>
<div className="max-h-[50vh] space-y-4 overflow-y-auto pr-1">
{groups.map(([group, perms]) => (
<fieldset key={group}>
<legend className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group}
</legend>
<div className="space-y-1">
{perms.map((p) => (
<label
key={p.key}
className="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
checked={draft.permissions.has(p.key)}
onChange={() => toggle(p.key)}
className="accent-primary"
/>
{p.label}
</label>
))}
</div>
</fieldset>
))}
</div>
<p className="text-xs text-muted-foreground">
Managing staff and roles is reserved for superadmins and
can&apos;t be added to a custom role.
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDraft(null)}>
Cancel
</Button>
<Button
onClick={() => void save()}
disabled={
saving || !draft.name.trim() || draft.permissions.size === 0
}
>
{saving ? "Saving…" : "Save role"}
</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
api,
staff,
AdminError,
type AdminRole,
type StaffMember,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { useConfirm } from "@/components/confirm";
const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback;
export function StaffTab() {
const confirm = useConfirm();
const [members, setMembers] = useState<StaffMember[] | null>(null);
const [roles, setRoles] = useState<AdminRole[]>([]);
const [selfId, setSelfId] = useState<string | null>(null);
const [handle, setHandle] = useState("");
const [grantRole, setGrantRole] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const [m, r, me] = await Promise.all([
staff.list(),
staff.roles(),
api.me(),
]);
setMembers(m);
setRoles(r);
setSelfId(me?.id ?? null);
setGrantRole(
(prev) =>
prev || r.find((x) => x.key === "moderator")?.id || r[0]?.id || "",
);
} catch (err) {
toast.error(errMsg(err, "Could not load staff"));
setMembers([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const run = async (key: string, fn: () => Promise<unknown>, ok: string) => {
setBusy(key);
try {
await fn();
toast.success(ok);
await load();
} catch (err) {
toast.error(errMsg(err, "That didn't work"));
} finally {
setBusy(null);
}
};
const grant = () => {
const h = handle.trim().replace(/^@/, "");
if (!h || !grantRole) return;
// Only someone new to the panel gets the welcome email; granting to an
// existing staff member just changes their role.
const existing = members?.some((m) => m.handle === h);
void run(
"grant",
() => staff.grant(h, grantRole),
existing
? `@${h}'s role was changed`
: `@${h} is now staff — we've emailed them how to log in`,
).then(() => setHandle(""));
};
const block = async (m: StaffMember) => {
const blocking = !m.blocked_at;
if (
blocking &&
!(await confirm({
title: `Block ${m.name}?`,
message:
"They keep their role but can't use the admin panel until you unblock them.",
confirmLabel: "Block",
destructive: true,
}))
) {
return;
}
void run(
m.user_id,
() => staff.setBlocked(m.user_id, blocking),
blocking ? "Blocked" : "Unblocked",
);
};
const revoke = async (m: StaffMember) => {
if (
!(await confirm({
title: `Remove ${m.name} from staff?`,
message:
"They lose admin access entirely. Their user account is unaffected.",
confirmLabel: "Remove",
destructive: true,
}))
) {
return;
}
void run(m.user_id, () => staff.revoke(m.user_id), "Removed from staff");
};
return (
<div className="space-y-6">
<section className="space-y-3 rounded-xl border border-border p-4">
<h2 className="text-sm font-medium text-foreground">
Add a staff member
</h2>
<div className="flex flex-wrap items-center gap-2">
<Input
value={handle}
onChange={(e) => setHandle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && grant()}
placeholder="@handle"
className="max-w-xs"
/>
<select
value={grantRole}
onChange={(e) => setGrantRole(e.target.value)}
className="h-10 rounded-lg border border-input bg-card px-3 text-sm"
>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<Button onClick={grant} disabled={busy === "grant" || !handle.trim()}>
Add
</Button>
</div>
</section>
{members === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<Table>
<THead>
<TR>
<TH>Person</TH>
<TH>Role</TH>
<TH>Status</TH>
<TH className="text-right">Actions</TH>
</TR>
</THead>
<tbody>
{members.map((m) => {
const self = m.user_id === selfId;
return (
<TR key={m.user_id}>
<TD>
<div className="flex items-center gap-3">
<Avatar
src={m.avatar_url ?? null}
name={m.name}
className="size-8"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{m.name}{" "}
{self && (
<span className="text-muted-foreground">(you)</span>
)}
</p>
<p className="truncate text-xs text-muted-foreground">
@{m.handle}
</p>
</div>
</div>
</TD>
<TD>
{/* Your own row is read-only: nobody changes their own
access, so a superadmin can't demote themselves into
a locked-out panel by accident. */}
{self ? (
<span className="text-sm">{m.role_name}</span>
) : (
<select
value={m.role_id}
disabled={busy === m.user_id}
onChange={(e) =>
void run(
m.user_id,
() => staff.changeRole(m.user_id, e.target.value),
"Role changed",
)
}
className="h-8 rounded-md border border-input bg-card px-2 text-xs"
>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
)}
</TD>
<TD>
{m.blocked_at ? (
<span className="rounded bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
Blocked
</span>
) : (
<span className="text-xs text-muted-foreground">
Active
</span>
)}
</TD>
<TD className="text-right">
{!self && (
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={busy === m.user_id}
onClick={() => void block(m)}
>
{m.blocked_at ? "Unblock" : "Block"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy === m.user_id}
onClick={() => void revoke(m)}
>
Remove
</Button>
</div>
)}
</TD>
</TR>
);
})}
</tbody>
</Table>
)}
</div>
);
}
+21 -12
View File
@@ -22,8 +22,10 @@ import { Input } from "@/components/ui/input";
import { RichTextarea } from "@/components/ui/rich-textarea";
import { fmtDate } from "@/lib/utils";
import { DiscipleLinkModal } from "./disciple-link-modal";
import { useStaff } from "@/components/staff-context";
export default function UsersPage() {
const { can } = useStaff();
const [q, setQ] = useState("");
const fetcher = useCallback(
(limit: number, offset: number) => api.users(q, limit, offset),
@@ -79,22 +81,29 @@ export default function UsersPage() {
}
function menuItems(u: AdminUser): MenuItem[] {
const items: MenuItem[] = [
{ label: "Send notification", onClick: () => setNotify(u) },
{ label: "Edit limits", onClick: () => setLimitsUser(u) },
{
// Only what this role may do. The BFF refuses the rest regardless.
const items: MenuItem[] = [];
if (can("users:notify")) {
items.push({ label: "Send notification", onClick: () => setNotify(u) });
}
if (can("users:limits")) {
items.push({ label: "Edit limits", onClick: () => setLimitsUser(u) });
}
if (can("disciples:manage")) {
items.push({
label: u.disciple_id ? "Change disciple link" : "Link direct disciple",
onClick: () => setDiscipleUser(u),
},
];
if (u.disciple_id) {
items.push({
label: "Remove disciple link",
onClick: () => removeDiscipleLink(u),
});
if (u.disciple_id) {
items.push({
label: "Remove disciple link",
onClick: () => removeDiscipleLink(u),
});
}
}
// No ban option for fellow admins (avoid locking each other out).
if (u.role !== "admin") {
// No ban option for staff: removing a staff member is a superadmin job,
// done from the Staff page — and the BFF refuses it from here anyway.
if (can("users:ban") && !u.admin_role) {
items.push({
label: u.banned_at ? "Unban account" : "Ban account",
onClick: () => toggleBan(u),
+84 -77
View File
@@ -14,6 +14,7 @@
*/
import { useEffect, useState } from "react";
import { Can } from "@/components/staff-context";
import { toast } from "sonner";
import {
vani,
@@ -142,80 +143,84 @@ export default function VaniPage() {
</p>
</header>
<section className="space-y-3 rounded-xl border border-border p-4">
<div className="flex flex-wrap items-center gap-2">
<input
value={link}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void pinByLink()}
placeholder="Paste a link — /transcriptions/720720sb-london"
className={`${field} min-w-0 flex-1`}
/>
<button
type="button"
disabled={busy || !link.trim()}
onClick={pinByLink}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Use this link
</button>
</div>
<p className="text-xs text-muted-foreground">
Or search by words. A slug only matches its own words, so paste the
link when you know exactly which item you want.
</p>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<label className="text-sm text-muted-foreground">
Date
<Can perm="vani:write">
<section className="space-y-3 rounded-xl border border-border p-4">
<div className="flex flex-wrap items-center gap-2">
<input
type="date"
value={day}
min={todayUTC()}
onChange={(e) => setDay(e.target.value)}
className={`${field} ml-2`}
value={link}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void pinByLink()}
placeholder="Paste a link — /transcriptions/720720sb-london"
className={`${field} min-w-0 flex-1`}
/>
</label>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search verses, chapters and lectures"
className={`${field} min-w-0 flex-1`}
/>
</div>
{!searchable ? (
<button
type="button"
disabled={busy || !link.trim()}
onClick={pinByLink}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Use this link
</button>
</div>
<p className="text-xs text-muted-foreground">
Type at least two characters to search.
Or search by words. A slug only matches its own words, so paste the
link when you know exactly which item you want.
</p>
) : visible.length === 0 ? (
<p className="text-xs text-muted-foreground">Nothing found.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{visible.map((c) => (
<li
key={c.doc_id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{c.title}</p>
<p className="truncate text-xs text-muted-foreground">
{vaniKindFor(c.collection)} · {c.snippet}
</p>
</div>
<button
type="button"
disabled={busy}
onClick={() => pin(c)}
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<label className="text-sm text-muted-foreground">
Date
<input
type="date"
value={day}
min={todayUTC()}
onChange={(e) => setDay(e.target.value)}
className={`${field} ml-2`}
/>
</label>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search verses, chapters and lectures"
className={`${field} min-w-0 flex-1`}
/>
</div>
{!searchable ? (
<p className="text-xs text-muted-foreground">
Type at least two characters to search.
</p>
) : visible.length === 0 ? (
<p className="text-xs text-muted-foreground">Nothing found.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{visible.map((c) => (
<li
key={c.doc_id}
className="flex items-center gap-3 px-3 py-2.5"
>
Set for {day}
</button>
</li>
))}
</ul>
)}
</section>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">
{c.title}
</p>
<p className="truncate text-xs text-muted-foreground">
{vaniKindFor(c.collection)} · {c.snippet}
</p>
</div>
<button
type="button"
disabled={busy}
onClick={() => pin(c)}
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Set for {day}
</button>
</li>
))}
</ul>
)}
</section>
</Can>
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">
@@ -243,14 +248,16 @@ export default function VaniPage() {
{p.payload.book_title ? ` · ${p.payload.book_title}` : ""}
</p>
</div>
<button
type="button"
disabled={busy}
onClick={() => clear(p)}
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Clear
</button>
<Can perm="vani:write">
<button
type="button"
disabled={busy}
onClick={() => clear(p)}
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Clear
</button>
</Can>
</li>
))}
</ul>
+15 -9
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { api, AdminError, type Me } from "@/lib/api";
import { api, staff, AdminError, type Me } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -17,17 +17,23 @@ export default function LoginPage() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Once authenticated, only admins may proceed. Non-admins are signed back
// out so an ordinary account can't sit in an authenticated-but-forbidden
// state against the admin origin.
async function gateAdmin(user: Me) {
if (user.role === "admin") {
// Once authenticated, only staff may proceed. Non-staff — and staff a
// superadmin has blocked — are signed back out, so an ordinary account
// can't sit in an authenticated-but-forbidden state against this origin.
async function gateAdmin(_user: Me) {
try {
await staff.me();
router.replace("/");
return;
} catch (err) {
await api.logout().catch(() => {});
setError(
err instanceof AdminError && /suspended/i.test(err.message)
? "Your admin access has been suspended."
: "This account doesn't have admin access.",
);
setStage("credentials");
}
await api.logout().catch(() => {});
setError("This account doesn't have admin access.");
setStage("credentials");
}
async function onCredentials(e: React.FormEvent) {
+56
View File
@@ -0,0 +1,56 @@
"use client";
/**
* The signed-in staff member's role and permissions, for the whole panel.
*
* Used to HIDE what someone can't do — nav entries, buttons, whole pages.
* Hiding is a courtesy, not the boundary: every /admin/* route on the BFF
* checks its own permission, so a hidden button that somehow fired would
* still be refused.
*/
import { createContext, useContext } from "react";
import type { StaffMe } from "@/lib/api";
const StaffContext = createContext<StaffMe | null>(null);
export const StaffProvider = StaffContext.Provider;
export function useStaff() {
const me = useContext(StaffContext);
const perms = new Set(me?.permissions ?? []);
return {
me,
can: (perm: string) => perms.has(perm),
};
}
/**
* Renders children only for staff holding `perm`; otherwise a short notice.
* Wraps whole pages, so landing on one by URL explains itself rather than
* showing a screen of 403 errors.
*/
export function RequirePermission({
perm,
children,
}: {
perm: string;
children: React.ReactNode;
}) {
const { can } = useStaff();
if (!can(perm)) {
return (
<div className="rounded-xl border border-border p-6 text-sm text-muted-foreground">
Your role doesn&apos;t include access to this section. A superadmin
can change that from the Staff page.
</div>
);
}
return <>{children}</>;
}
/** Renders children only when the signed-in role holds `perm`. */
export function Can({ perm, children }: { perm: string; children: React.ReactNode }) {
const { can } = useStaff();
return can(perm) ? <>{children}</> : null;
}
+184
View File
@@ -121,6 +121,8 @@ export type AdminUser = {
name: string;
avatar_url: string | null;
role: string;
/** Staff role key (superadmin/admin/moderator/custom), absent for users. */
admin_role?: string | null;
plan: string;
email_verified_at: string | null;
banned_at: string | null;
@@ -561,3 +563,185 @@ export const vani = {
);
},
};
// ─── Staff, roles and permissions ───────────────────────────────────────────
/** The signed-in staff member's role and effective permissions. */
export type StaffMe = {
role_key: string;
role_name: string;
permissions: string[];
};
/** Held only by superadmins: managing other staff and roles. */
export const MANAGE_STAFF = "admins:manage";
export type Permission = { key: string; group: string; label: string };
export type AdminRole = {
id: string;
key: string;
name: string;
description: string;
is_system: boolean;
permissions: string[];
member_count: number;
};
export type StaffMember = {
user_id: string;
handle: string;
name: string;
email?: string | null;
avatar_url?: string | null;
role_id: string;
role_key: string;
role_name: string;
blocked_at?: string | null;
};
export const staff = {
/** Resolves only for staff; a non-staff or blocked account gets a 403. */
me: () => call<StaffMe>("/admin/me").then((r) => r.data),
catalog: () => call<Permission[]>("/admin/permissions").then((r) => r.data),
list: () => call<StaffMember[]>("/admin/staff").then((r) => r.data),
grant: (handle: string, roleId: string) =>
call<StaffMember>("/admin/staff", {
method: "POST",
body: JSON.stringify({ handle, role_id: roleId }),
}).then((r) => r.data),
changeRole: (userId: string, roleId: string) =>
call<StaffMember>(`/admin/staff/${userId}`, {
method: "PATCH",
body: JSON.stringify({ role_id: roleId }),
}).then((r) => r.data),
revoke: (userId: string) =>
call<void>(`/admin/staff/${userId}`, { method: "DELETE" }),
setBlocked: (userId: string, blocked: boolean) =>
call<StaffMember>(`/admin/staff/${userId}/block`, {
method: blocked ? "POST" : "DELETE",
}).then((r) => r.data),
roles: () => call<AdminRole[]>("/admin/roles").then((r) => r.data),
createRole: (input: { name: string; description: string; permissions: string[] }) =>
call<AdminRole>("/admin/roles", { method: "POST", body: JSON.stringify(input) }).then(
(r) => r.data,
),
updateRole: (
id: string,
input: { name: string; description: string; permissions: string[] },
) =>
call<AdminRole>(`/admin/roles/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
}).then((r) => r.data),
deleteRole: (id: string) => call<void>(`/admin/roles/${id}`, { method: "DELETE" }),
};
// ─── Quizzes ────────────────────────────────────────────────────────────────
export type QuizTargetKind = "chapter" | "lecture" | "conversation" | "standalone";
export type QuizOption = { id?: string; ord?: number; body: string; is_correct: boolean };
export type QuizQuestion = {
id?: string;
ord?: number;
prompt: string;
explanation: string;
/** "Select all that apply": one or more options correct. */
multi: boolean;
options: QuizOption[];
};
export type Quiz = {
id: string;
title: string;
description: string;
target_kind: QuizTargetKind;
target_path?: string | null;
target_title: string;
published_at?: string | null;
created_at: string;
updated_at: string;
question_count: number;
attempt_count?: number;
completed_count?: number;
questions?: QuizQuestion[];
};
export type QuizDraft = {
title: string;
description: string;
/** Pasted app link; empty for a stand-alone quiz. */
target_url: string;
/** Optional override for the resolved content title. */
target_title: string;
/** Omitted once a quiz has attempts — its questions are frozen. */
questions?: QuizQuestion[];
};
export type QuizParticipant = {
user_id: string;
handle: string;
name: string;
avatar_url?: string | null;
started_at: string;
completed_at?: string | null;
answered: number;
score: number;
total: number;
total_ms: number;
};
export type QuizQuestionStat = {
id: string;
ord: number;
prompt: string;
multi: boolean;
answered: number;
correct: number;
avg_ms: number;
median_ms: number;
options: (QuizOption & { id: string; picks: number })[];
};
export type QuizResultItem = QuizQuestion & {
chosen_option_ids: string[];
is_correct: boolean;
response_ms?: number | null;
};
export type QuizResult = {
quiz: Quiz;
score: number;
total: number;
started_at: string;
completed_at?: string | null;
total_ms: number;
items: QuizResultItem[];
};
export const quizzes = {
list: () => call<Quiz[]>("/admin/quizzes").then((r) => r.data),
get: (id: string) => call<Quiz>(`/admin/quizzes/${id}`).then((r) => r.data),
create: (draft: QuizDraft) =>
call<Quiz>("/admin/quizzes", { method: "POST", body: JSON.stringify(draft) }).then(
(r) => r.data,
),
update: (id: string, draft: QuizDraft) =>
call<Quiz>(`/admin/quizzes/${id}`, { method: "PUT", body: JSON.stringify(draft) }).then(
(r) => r.data,
),
setPublished: (id: string, on: boolean) =>
call<Quiz>(`/admin/quizzes/${id}/publish`, { method: on ? "POST" : "DELETE" }).then(
(r) => r.data,
),
remove: (id: string) => call<void>(`/admin/quizzes/${id}`, { method: "DELETE" }),
participants: (id: string) =>
call<QuizParticipant[]>(`/admin/quizzes/${id}/participants`).then((r) => r.data),
participant: (id: string, userId: string) =>
call<QuizResult>(`/admin/quizzes/${id}/participants/${userId}`).then((r) => r.data),
responses: (id: string) =>
call<QuizQuestionStat[]>(`/admin/quizzes/${id}/responses`).then((r) => r.data),
};