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() {
+ );
+ })}
+
+ );
+}
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.
+
+ );
+}
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 (
+
+
+
+ {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) => (
+
+
+
+ Question {qi + 1}
+
+ {!locked && (
+
+
+
+
+
+ )}
+
+
+ {locked ? (
+
+
+
+
+ {q.multi && (
+
+ Multiple answers
+
+ )}
+
+ {q.options.map((o, oi) => (
+
+
+
+ ))}
+
+
+ ) : (
+
+ editQ(qi, { prompt: v })}
+ rows={2}
+ maxLength={2000}
+ placeholder="Question"
+ />
+
+
+ editQ(qi, { explanation: v })}
+ rows={2}
+ maxLength={2000}
+ placeholder="Explanation shown with the answer (optional)"
+ />
+