400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
"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's link, e.g. /books/bg/2 or
|
||
/transcriptions/720720sb-london. Leave empty for a stand-alone
|
||
quiz. Each chapter, lecture or conversation can have only one
|
||
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 flex-wrap items-center gap-x-2 gap-y-0.5 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="basis-full pl-6 text-xs text-muted-foreground sm:basis-auto sm:pl-0">
|
||
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>
|
||
);
|
||
}
|