diff --git a/app/(dashboard)/broadcasts/page.tsx b/app/(dashboard)/broadcasts/page.tsx index e019b4b..9cb86e2 100644 --- a/app/(dashboard)/broadcasts/page.tsx +++ b/app/(dashboard)/broadcasts/page.tsx @@ -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() {

- + + +

Recent

@@ -212,14 +215,16 @@ function Composer({ onDone }: { onDone: () => void }) { > Save draft - + + +
); @@ -268,33 +273,37 @@ function BroadcastRow({
- {!live && ( + + {!live && ( + + )} + + - )} - +
); diff --git a/app/(dashboard)/feedback/page.tsx b/app/(dashboard)/feedback/page.tsx index 5e76a40..0f9a920 100644 --- a/app/(dashboard)/feedback/page.tsx +++ b/app/(dashboard)/feedback/page.tsx @@ -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() { {fmtDate(f.created_at)}
- {!f.resolved_at && !f.acknowledged_at ? ( - - ) : null} - {!f.resolved_at ? ( + + {!f.resolved_at && !f.acknowledged_at ? ( + + ) : null} + {!f.resolved_at ? ( + + ) : null} + + - ) : null} - +
@@ -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( diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index aa8539e..ece0cc7 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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(null); + const [staffMe, setStaffMe] = useState(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 (
Loading… @@ -91,29 +143,35 @@ export default function DashboardLayout({

Admin

{me.name}

@{me.handle}

+

+ {staffMe.role_name} +

diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 4e0dc3d..9c8d7a1 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -54,7 +54,7 @@ export default function OverviewPage() {

Feedback by category

- {stats.feedback_by_category.length === 0 ? ( + {!stats.feedback_by_category?.length ? (

No feedback yet.

) : (
diff --git a/app/(dashboard)/quizzes/[id]/page.tsx b/app/(dashboard)/quizzes/[id]/page.tsx new file mode 100644 index 0000000..d034301 --- /dev/null +++ b/app/(dashboard)/quizzes/[id]/page.tsx @@ -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(null); + const [tab, setTab] = useState("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

Loading…

; + + 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 ( +
+
+
+ + ← Quizzes + +

+ {quiz.title} +

+

+ {KIND_LABEL[quiz.target_kind]} + {quiz.target_title && ` · ${quiz.target_title}`} + {quiz.target_path && ( + + ({quiz.target_path}) + + )} +

+
+
+ + + + + + +
+
+ +
+ {tabs + .filter((t) => t.show) + .map((t) => ( + + ))} +
+ + {tab === "edit" && ( + + )} + {tab === "participants" && } + {tab === "responses" && } +
+ ); +} diff --git a/app/(dashboard)/quizzes/[id]/results.tsx b/app/(dashboard)/quizzes/[id]/results.tsx new file mode 100644 index 0000000..969b08a --- /dev/null +++ b/app/(dashboard)/quizzes/[id]/results.tsx @@ -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(null); + const [open, setOpen] = useState(null); + const [detail, setDetail] = useState(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

Loading…

; + if (rows.length === 0) + return ( +

+ Nobody has taken this quiz yet. +

+ ); + + return ( + <> + + + + + + + + + + + + {rows.map((p) => ( + + + + + + + + ))} + +
PersonScoreProgressTimeStarted
+ + + {p.completed_at ? ( + + {p.score} / {p.total} + + ) : ( + — + )} + + {p.completed_at ? ( + Finished + ) : ( + + {p.answered} of {p.total} answered + + )} + {fmtMs(p.total_ms)} + {fmtDate(p.started_at)} +
+ + setOpen(null)} + title={open ? `${open.name}'s answers` : ""} + > + {!detail ? ( +

Loading…

+ ) : ( +
+

+ {detail.completed_at + ? `Scored ${detail.score} of ${detail.total}` + : "Still in progress"}{" "} + · {fmtMs(detail.total_ms)} in total +

+ {detail.items.map((it, i) => ( +
+
+

+ {i + 1}. +

+ + {fmtMs(it.response_ms)} + +
+
    + {it.options.map((o) => { + const chosen = !!o.id && it.chosen_option_ids.includes(o.id); + return ( +
  • + + {chosen && ( + ← their answer + )} +
  • + ); + })} +
+ {it.chosen_option_ids.length === 0 && ( +

+ Not answered yet. +

+ )} +
+ ))} +
+ )} +
+ + ); +} + +export function Responses({ quizId }: { quizId: string }) { + const [stats, setStats] = useState(null); + + useEffect(() => { + quizzes + .responses(quizId) + .then(setStats) + .catch((err) => { + toast.error(errMsg(err, "Could not load responses")); + setStats([]); + }); + }, [quizId]); + + if (stats === null) + return

Loading…

; + + return ( +
    + {stats.map((q, i) => { + const pct = q.answered ? Math.round((q.correct / q.answered) * 100) : 0; + return ( +
  1. +
    +

    + {i + 1}. + {q.multi && ( + + Multiple answers + + )} +

    +
    +

    + {pct}%{" "} + correct · {q.answered} answered +

    +

    + avg {fmtMs(q.avg_ms)} · median {fmtMs(q.median_ms)} +

    +
    +
    +
      + {q.options.map((o) => { + const share = q.answered ? (o.picks / q.answered) * 100 : 0; + return ( +
    • +
      +
      + + + {o.is_correct && ( + ✓ correct + )} + + + {o.picks} · {Math.round(share)}% + +
      +
    • + ); + })} +
    +
  2. + ); + })} +
+ ); +} diff --git a/app/(dashboard)/quizzes/labels.ts b/app/(dashboard)/quizzes/labels.ts new file mode 100644 index 0000000..4749b58 --- /dev/null +++ b/app/(dashboard)/quizzes/labels.ts @@ -0,0 +1,17 @@ +import type { Quiz } from "@/lib/api"; + +export const KIND_LABEL: Record = { + 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`; +} diff --git a/app/(dashboard)/quizzes/new/page.tsx b/app/(dashboard)/quizzes/new/page.tsx new file mode 100644 index 0000000..ea83fb2 --- /dev/null +++ b/app/(dashboard)/quizzes/new/page.tsx @@ -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 ( + +
+

New quiz

+ router.replace(`/quizzes/${q.id}`)} + /> +
+
+ ); +} diff --git a/app/(dashboard)/quizzes/page.tsx b/app/(dashboard)/quizzes/page.tsx new file mode 100644 index 0000000..1904843 --- /dev/null +++ b/app/(dashboard)/quizzes/page.tsx @@ -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(null); + + useEffect(() => { + quizzes + .list() + .then(setList) + .catch((err) => { + toast.error( + err instanceof AdminError ? err.message : "Could not load quizzes", + ); + setList([]); + }); + }, []); + + return ( +
+
+
+

Quizzes

+

+ 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's quiz list. +

+
+ + + + + +
+ + {list === null ? ( +

Loading…

+ ) : list.length === 0 ? ( +

No quizzes yet.

+ ) : ( + + + + + + + + + + + + {list.map((q) => ( + + + + + + + + ))} + +
QuizAttached toQuestionsTakenStatus
+ + {q.title} + +

+ Updated {fmtDate(q.updated_at)} +

+
+

{KIND_LABEL[q.target_kind]}

+ {q.target_title && ( +

+ {q.target_title} +

+ )} +
{q.question_count} + {q.completed_count ?? 0} finished + + {" "} + / {q.attempt_count ?? 0} started + + + {q.published_at ? ( + + Published + + ) : ( + Draft + )} +
+ )} +
+ ); +} diff --git a/app/(dashboard)/quizzes/quiz-editor.tsx b/app/(dashboard)/quizzes/quiz-editor.tsx new file mode 100644 index 0000000..c0dbf92 --- /dev/null +++ b/app/(dashboard)/quizzes/quiz-editor.tsx @@ -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( + initial?.questions?.length ? initial.questions : [blankQuestion()], + ); + const [saving, setSaving] = useState(false); + + const editQ = (i: number, patch: Partial) => + 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 ( +
+
+ setTitle(e.target.value)} + placeholder="Quiz title" + maxLength={200} + /> + +
+
+ setTargetUrl(e.target.value)} + placeholder="Link to a chapter, lecture or conversation" + /> +

+ Paste the page's link, e.g. /books/bg/2 or + /transcriptions/720720sb-london. Leave empty for a stand-alone + quiz. +

+
+ {targetUrl.trim() && ( +
+ setTargetTitle(e.target.value)} + placeholder="Content title (filled in automatically)" + /> +

+ Override only if the automatic title reads badly. +

+
+ )} +
+
+ + {taken && !readOnly && ( +

+ {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. +

+ )} + +
    + {questions.map((q, qi) => ( +
  1. +
    +

    + Question {qi + 1} +

    + {!locked && ( +
    + + + +
    + )} +
    + + {locked ? ( +
    +

    + +

    + {q.multi && ( +

    + Multiple answers +

    + )} +
      + {q.options.map((o, oi) => ( +
    • + +
    • + ))} +
    +
    + ) : ( +
    + editQ(qi, { prompt: v })} + rows={2} + maxLength={2000} + placeholder="Question" + /> + +
    + + {q.multi + ? "Options — tick every correct one" + : "Options — select the correct one"} + + {q.options.map((o, oi) => ( +
    + markCorrect(qi, oi)} + className="mt-3 accent-primary" + aria-label={`Option ${oi + 1} is correct`} + /> + editOption(qi, oi, v)} + rows={1} + maxLength={500} + placeholder={`Option ${oi + 1}`} + className="flex-1" + /> + +
    + ))} + {q.options.length < MAX_OPTIONS && ( + + )} +
    + editQ(qi, { explanation: v })} + rows={2} + maxLength={2000} + placeholder="Explanation shown with the answer (optional)" + /> +
    + )} +
  2. + ))} +
+ +
+ {!locked ? ( + + ) : ( + + )} + {!readOnly && ( + + )} +
+
+ ); +} diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index b9c2274..f7e2807 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -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 ( <> - + + +
)} @@ -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); } diff --git a/app/(dashboard)/reward-gifts/page.tsx b/app/(dashboard)/reward-gifts/page.tsx index dc54740..ca51e25 100644 --- a/app/(dashboard)/reward-gifts/page.tsx +++ b/app/(dashboard)/reward-gifts/page.tsx @@ -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

- 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.

- - setCatalog((prev) => (prev ? [...prev, reward] : [reward])) - } - /> + + + setCatalog((prev) => (prev ? [...prev, reward] : [reward])) + } + /> + {catalog === null ? (

Loading…

@@ -122,27 +127,31 @@ export default function RewardGiftsPage() { {r.name}

- - {/* 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_") && ( + - )} + + {/* 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_") && ( + + )} +
))} diff --git a/app/(dashboard)/staff/page.tsx b/app/(dashboard)/staff/page.tsx new file mode 100644 index 0000000..7c45b93 --- /dev/null +++ b/app/(dashboard)/staff/page.tsx @@ -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 ( +
+
+

+ Staff & roles +

+

+ Decide who can use this panel and what each role may do. +

+
+ +
+ {(["staff", "roles"] as const).map((t) => ( + + ))} +
+ + {tab === "staff" ? : } +
+ ); +} diff --git a/app/(dashboard)/staff/roles-tab.tsx b/app/(dashboard)/staff/roles-tab.tsx new file mode 100644 index 0000000..fff0aa2 --- /dev/null +++ b/app/(dashboard)/staff/roles-tab.tsx @@ -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; +}; + +export function RolesTab() { + const confirm = useConfirm(); + const [roles, setRoles] = useState(null); + const [catalog, setCatalog] = useState([]); + const [draft, setDraft] = useState(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(); + 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

Loading…

; + + return ( +
+
+ +
+ +
    + {roles.map((r) => ( +
  • +
    +
    +

    + {r.name}{" "} + {r.is_system && ( + + System + + )} +

    + {r.description && ( +

    + {r.description} +

    + )} +

    + {r.member_count} {r.member_count === 1 ? "member" : "members"} +

    +
    + {!r.is_system && ( +
    + + +
    + )} +
    +
    + {r.permissions.includes("*") ? ( + + Everything, including managing staff + + ) : ( + r.permissions.map((p) => ( + + {labelOf.get(p) ?? p} + + )) + )} +
    +
  • + ))} +
+ + setDraft(null)} + title={draft?.id ? "Edit role" : "New role"} + > + {draft && ( +
+ setDraft({ ...draft, name: e.target.value })} + placeholder="Role name — e.g. Quiz editor" + maxLength={80} + /> + + setDraft({ ...draft, description: e.target.value }) + } + placeholder="What is this role for? (optional)" + /> +
+ {groups.map(([group, perms]) => ( +
+ + {group} + +
+ {perms.map((p) => ( + + ))} +
+
+ ))} +
+

+ Managing staff and roles is reserved for superadmins and + can't be added to a custom role. +

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/app/(dashboard)/staff/staff-tab.tsx b/app/(dashboard)/staff/staff-tab.tsx new file mode 100644 index 0000000..7ed00d4 --- /dev/null +++ b/app/(dashboard)/staff/staff-tab.tsx @@ -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(null); + const [roles, setRoles] = useState([]); + const [selfId, setSelfId] = useState(null); + const [handle, setHandle] = useState(""); + const [grantRole, setGrantRole] = useState(""); + const [busy, setBusy] = useState(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, 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 ( +
+
+

+ Add a staff member +

+
+ setHandle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && grant()} + placeholder="@handle" + className="max-w-xs" + /> + + +
+
+ + {members === null ? ( +

Loading…

+ ) : ( + + + + + + + + + + + {members.map((m) => { + const self = m.user_id === selfId; + return ( + + + + + + + ); + })} + +
PersonRoleStatusActions
+
+ +
+

+ {m.name}{" "} + {self && ( + (you) + )} +

+

+ @{m.handle} +

+
+
+
+ {/* 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 ? ( + {m.role_name} + ) : ( + + )} + + {m.blocked_at ? ( + + Blocked + + ) : ( + + Active + + )} + + {!self && ( +
+ + +
+ )} +
+ )} +
+ ); +} diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx index 7a3e1a7..ae73420 100644 --- a/app/(dashboard)/users/page.tsx +++ b/app/(dashboard)/users/page.tsx @@ -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), diff --git a/app/(dashboard)/vani/page.tsx b/app/(dashboard)/vani/page.tsx index c7232dc..d56aa48 100644 --- a/app/(dashboard)/vani/page.tsx +++ b/app/(dashboard)/vani/page.tsx @@ -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() {

-
-
- 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`} - /> - -
-

- Or search by words. A slug only matches its own words, so paste the - link when you know exactly which item you want. -

- -
-

- 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.

- ) : visible.length === 0 ? ( -

Nothing found.

- ) : ( -
    - {visible.map((c) => ( -
  • -
    -

    {c.title}

    -

    - {vaniKindFor(c.collection)} · {c.snippet} -

    -
    - -
  • - ))} -
- )} -
+
+

+ {c.title} +

+

+ {vaniKindFor(c.collection)} · {c.snippet} +

+
+ + + ))} + + )} + +

@@ -243,14 +248,16 @@ export default function VaniPage() { {p.payload.book_title ? ` · ${p.payload.book_title}` : ""}

- + + + ))} diff --git a/app/login/page.tsx b/app/login/page.tsx index 8a81a49..5b74be4 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -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(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) { diff --git a/components/staff-context.tsx b/components/staff-context.tsx new file mode 100644 index 0000000..b5e0f69 --- /dev/null +++ b/components/staff-context.tsx @@ -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(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 ( +
+ Your role doesn't include access to this section. A superadmin + can change that from the Staff page. +
+ ); + } + 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; +} diff --git a/lib/api.ts b/lib/api.ts index b54bd43..ea6b649 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -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("/admin/me").then((r) => r.data), + catalog: () => call("/admin/permissions").then((r) => r.data), + list: () => call("/admin/staff").then((r) => r.data), + grant: (handle: string, roleId: string) => + call("/admin/staff", { + method: "POST", + body: JSON.stringify({ handle, role_id: roleId }), + }).then((r) => r.data), + changeRole: (userId: string, roleId: string) => + call(`/admin/staff/${userId}`, { + method: "PATCH", + body: JSON.stringify({ role_id: roleId }), + }).then((r) => r.data), + revoke: (userId: string) => + call(`/admin/staff/${userId}`, { method: "DELETE" }), + setBlocked: (userId: string, blocked: boolean) => + call(`/admin/staff/${userId}/block`, { + method: blocked ? "POST" : "DELETE", + }).then((r) => r.data), + + roles: () => call("/admin/roles").then((r) => r.data), + createRole: (input: { name: string; description: string; permissions: string[] }) => + call("/admin/roles", { method: "POST", body: JSON.stringify(input) }).then( + (r) => r.data, + ), + updateRole: ( + id: string, + input: { name: string; description: string; permissions: string[] }, + ) => + call(`/admin/roles/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }).then((r) => r.data), + deleteRole: (id: string) => call(`/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("/admin/quizzes").then((r) => r.data), + get: (id: string) => call(`/admin/quizzes/${id}`).then((r) => r.data), + create: (draft: QuizDraft) => + call("/admin/quizzes", { method: "POST", body: JSON.stringify(draft) }).then( + (r) => r.data, + ), + update: (id: string, draft: QuizDraft) => + call(`/admin/quizzes/${id}`, { method: "PUT", body: JSON.stringify(draft) }).then( + (r) => r.data, + ), + setPublished: (id: string, on: boolean) => + call(`/admin/quizzes/${id}/publish`, { method: on ? "POST" : "DELETE" }).then( + (r) => r.data, + ), + remove: (id: string) => call(`/admin/quizzes/${id}`, { method: "DELETE" }), + participants: (id: string) => + call(`/admin/quizzes/${id}/participants`).then((r) => r.data), + participant: (id: string, userId: string) => + call(`/admin/quizzes/${id}/participants/${userId}`).then((r) => r.data), + responses: (id: string) => + call(`/admin/quizzes/${id}/responses`).then((r) => r.data), +};