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
+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>
);
}