feat: quiz
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /quizzes — every published quiz, with where you are in each.
|
||||
*
|
||||
* Reached from the Quizzes card on the search page. Quizzes are deliberately
|
||||
* not in search results: they're something to do, not something to look up.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { quiz, quizActionLabel, type QuizCard } from "@/lib/quiz";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const KIND: Record<QuizCard["target_kind"], string> = {
|
||||
chapter: "Book chapter",
|
||||
lecture: "Lecture",
|
||||
conversation: "Conversation",
|
||||
standalone: "Quiz",
|
||||
};
|
||||
|
||||
type Filter = "all" | "todo" | "done";
|
||||
|
||||
export default function QuizzesPage() {
|
||||
const { user } = useAuth();
|
||||
const [list, setList] = useState<QuizCard[] | null>(null);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
quiz
|
||||
.list()
|
||||
.then((l) => !cancelled && setList(l))
|
||||
.catch(() => !cancelled && setList([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const shown = useMemo(
|
||||
() =>
|
||||
(list ?? []).filter((q) =>
|
||||
filter === "all"
|
||||
? true
|
||||
: filter === "done"
|
||||
? q.my.state === "completed"
|
||||
: q.my.state !== "completed",
|
||||
),
|
||||
[list, filter],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
|
||||
<header className="mb-6">
|
||||
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground sm:text-3xl">
|
||||
Quizzes
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Test what you've read and heard. Each quiz can be taken once, and
|
||||
only you can see your results.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{user && (
|
||||
<div className="mb-4 flex gap-1">
|
||||
{(
|
||||
[
|
||||
["all", "All"],
|
||||
["todo", "To do"],
|
||||
["done", "Finished"],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setFilter(k)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-sm transition-colors",
|
||||
filter === k
|
||||
? "bg-primary text-primary-foreground dark:text-white"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{list.length === 0
|
||||
? "No quizzes yet — check back soon."
|
||||
: "Nothing here."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{shown.map((q) => {
|
||||
const done = q.my.state === "completed";
|
||||
return (
|
||||
<li key={q.id}>
|
||||
<Link
|
||||
href={`/quizzes/${q.id}`}
|
||||
className="flex h-full flex-col rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
|
||||
>
|
||||
<p className="text-xs font-medium text-primary">
|
||||
{KIND[q.target_kind]}
|
||||
</p>
|
||||
<p className="mt-1 font-semibold leading-snug text-foreground">
|
||||
{q.title}
|
||||
</p>
|
||||
{q.target_title && (
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
||||
{q.target_title}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{q.question_count}{" "}
|
||||
{q.question_count === 1 ? "question" : "questions"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 font-medium",
|
||||
done ? "text-muted-foreground" : "text-primary",
|
||||
)}
|
||||
>
|
||||
<ClipboardCheck className="size-3.5" />
|
||||
{quizActionLabel(q.my.state)}
|
||||
{done &&
|
||||
q.my.score !== undefined &&
|
||||
` · ${q.my.score}/${q.my.total}`}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user