The All tab is now a click-through of category cards — Books, Lectures & Conversations, Other quizzes — each opening its books or years, then the quizzes themselves. Only categories with quizzes get a card. The position and tab live in the URL (?in=books&book=bg, ?tab=todo), so refresh, Back and shared links keep your place. Book quizzes list in chapter order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
5.6 KiB
TypeScript
214 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The "All" tab of /quizzes, as a drill-down:
|
|
*
|
|
* All → Books → a book → its quizzes
|
|
* → Lectures & Conversations → a year → its quizzes
|
|
* → Other quizzes (stand-alone)
|
|
*
|
|
* Where you are lives in the URL (?in=books&book=bg, ?in=lectures&year=1972)
|
|
* so a refresh, Back, or a shared link lands in the same place. Every level
|
|
* only offers categories that actually hold quizzes; a stale or hand-typed
|
|
* URL pointing at an empty one falls back to its parent.
|
|
*/
|
|
|
|
import Link from "next/link";
|
|
import {
|
|
BookOpen,
|
|
CalendarDays,
|
|
ChevronRight,
|
|
ClipboardCheck,
|
|
Mic,
|
|
} from "lucide-react";
|
|
import type { QuizCard } from "@/lib/quiz";
|
|
import { groupQuizzes, type QuizGroup } from "@/lib/quiz-groups";
|
|
import { QuizCategoryCard } from "./quiz-category-card";
|
|
import { QuizGrid } from "./quiz-grid";
|
|
|
|
export type QuizBrowsePath = {
|
|
in?: string | null;
|
|
book?: string | null;
|
|
year?: string | null;
|
|
};
|
|
|
|
const BOOKS = "books";
|
|
const LECTURES = "lectures";
|
|
const OTHER = "other";
|
|
|
|
/** /quizzes URL for a browse position (the All tab is the default). */
|
|
export function quizzesHref(p: QuizBrowsePath): string {
|
|
const qs = new URLSearchParams();
|
|
if (p.in) qs.set("in", p.in);
|
|
if (p.book) qs.set("book", p.book);
|
|
if (p.year) qs.set("year", p.year);
|
|
const s = qs.toString();
|
|
return s ? `/quizzes?${s}` : "/quizzes";
|
|
}
|
|
|
|
export function QuizBrowser({
|
|
quizzes,
|
|
path,
|
|
}: {
|
|
quizzes: QuizCard[];
|
|
path: QuizBrowsePath;
|
|
}) {
|
|
const { books, lectures, other } = groupQuizzes(quizzes);
|
|
const all = (gs: QuizGroup[]) => gs.flatMap((g) => g.quizzes);
|
|
|
|
const book =
|
|
path.in === BOOKS ? books.find((g) => g.key === path.book) : undefined;
|
|
const year =
|
|
path.in === LECTURES
|
|
? lectures.find((g) => g.key === path.year)
|
|
: undefined;
|
|
|
|
// Books → a book: its quizzes.
|
|
if (book) {
|
|
return (
|
|
<Level crumbs={[["Books", quizzesHref({ in: BOOKS })], [book.label]]}>
|
|
<QuizGrid quizzes={book.quizzes} />
|
|
</Level>
|
|
);
|
|
}
|
|
// Lectures → a year: its quizzes.
|
|
if (year) {
|
|
return (
|
|
<Level
|
|
crumbs={[
|
|
["Lectures & Conversations", quizzesHref({ in: LECTURES })],
|
|
[year.label],
|
|
]}
|
|
>
|
|
<QuizGrid quizzes={year.quizzes} />
|
|
</Level>
|
|
);
|
|
}
|
|
// Books: a card per book.
|
|
if (path.in === BOOKS && books.length > 0) {
|
|
return (
|
|
<Level crumbs={[["Books"]]}>
|
|
<CardGrid>
|
|
{books.map((g) => (
|
|
<QuizCategoryCard
|
|
key={g.key}
|
|
href={quizzesHref({ in: BOOKS, book: g.key })}
|
|
label={g.label}
|
|
quizzes={g.quizzes}
|
|
icon={<BookOpen className="size-5" />}
|
|
/>
|
|
))}
|
|
</CardGrid>
|
|
</Level>
|
|
);
|
|
}
|
|
// Lectures: a card per year.
|
|
if (path.in === LECTURES && lectures.length > 0) {
|
|
return (
|
|
<Level crumbs={[["Lectures & Conversations"]]}>
|
|
<CardGrid>
|
|
{lectures.map((g) => (
|
|
<QuizCategoryCard
|
|
key={g.key}
|
|
href={quizzesHref({ in: LECTURES, year: g.key })}
|
|
label={g.label}
|
|
quizzes={g.quizzes}
|
|
icon={<CalendarDays className="size-5" />}
|
|
/>
|
|
))}
|
|
</CardGrid>
|
|
</Level>
|
|
);
|
|
}
|
|
// Stand-alone quizzes.
|
|
if (path.in === OTHER && other.length > 0) {
|
|
return (
|
|
<Level crumbs={[["Other quizzes"]]}>
|
|
<QuizGrid quizzes={other} />
|
|
</Level>
|
|
);
|
|
}
|
|
|
|
// Top level: one card per category that has quizzes.
|
|
return (
|
|
<CardGrid>
|
|
{books.length > 0 && (
|
|
<QuizCategoryCard
|
|
href={quizzesHref({ in: BOOKS })}
|
|
label="Books"
|
|
quizzes={all(books)}
|
|
icon={<BookOpen className="size-5" />}
|
|
/>
|
|
)}
|
|
{lectures.length > 0 && (
|
|
<QuizCategoryCard
|
|
href={quizzesHref({ in: LECTURES })}
|
|
label="Lectures & Conversations"
|
|
quizzes={all(lectures)}
|
|
icon={<Mic className="size-5" />}
|
|
/>
|
|
)}
|
|
{other.length > 0 && (
|
|
<QuizCategoryCard
|
|
href={quizzesHref({ in: OTHER })}
|
|
label="Other quizzes"
|
|
quizzes={other}
|
|
icon={<ClipboardCheck className="size-5" />}
|
|
/>
|
|
)}
|
|
</CardGrid>
|
|
);
|
|
}
|
|
|
|
function CardGrid({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 [&>*]:min-w-0">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A level below the top: a breadcrumb back up, then its content. */
|
|
function Level({
|
|
crumbs,
|
|
children,
|
|
}: {
|
|
/** [label, href?] — the last (current) one has no link. */
|
|
crumbs: [string, string?][];
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<nav
|
|
aria-label="Quiz categories"
|
|
className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
|
|
>
|
|
<Link
|
|
href={quizzesHref({})}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
All quizzes
|
|
</Link>
|
|
{crumbs.map(([label, href]) => (
|
|
<span key={label} className="flex min-w-0 items-center gap-1.5">
|
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
|
{href ? (
|
|
<Link
|
|
href={href}
|
|
className="truncate text-muted-foreground hover:text-foreground"
|
|
>
|
|
{label}
|
|
</Link>
|
|
) : (
|
|
<span className="truncate font-medium text-foreground">
|
|
{label}
|
|
</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</nav>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|