Files
2026-10-16 15:23:58 +05:30

400 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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. 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>
);
}