feat: quiz
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user