Quiz list: drill down by Books → book and Lectures → year

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>
This commit is contained in:
gosangam
2026-10-18 13:23:46 +05:30
co-authored by Claude Opus 5.5
parent 3af4e1bcd3
commit 0a23ab59f7
6 changed files with 299 additions and 80 deletions
-74
View File
@@ -1,74 +0,0 @@
import type { QuizSections } from "@/lib/quiz-groups";
import { QuizGrid } from "./quiz-grid";
/**
* The "All" tab: Books → book, Lectures & Conversations → year, then the
* stand-alone quizzes. groupQuizzes only returns non-empty groups, and each
* section here renders only when it has groups, so nothing empty shows.
*/
export function GroupedQuizzes({ sections }: { sections: QuizSections }) {
return (
<div className="space-y-10">
{sections.books.length > 0 && (
<Section title="Books">
{sections.books.map((g) => (
<Group key={g.key} label={g.label} count={g.quizzes.length}>
<QuizGrid quizzes={g.quizzes} />
</Group>
))}
</Section>
)}
{sections.lectures.length > 0 && (
<Section title="Lectures & Conversations">
{sections.lectures.map((g) => (
<Group key={g.key} label={g.label} count={g.quizzes.length}>
<QuizGrid quizzes={g.quizzes} />
</Group>
))}
</Section>
)}
{sections.other.length > 0 && (
<Section title="Other quizzes">
<QuizGrid quizzes={sections.other} />
</Section>
)}
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-5">
<h2 className="font-[family-name:var(--font-title)] text-lg font-semibold text-foreground sm:text-xl">
{title}
</h2>
{children}
</section>
);
}
function Group({
label,
count,
children,
}: {
label: string;
count: number;
children: React.ReactNode;
}) {
return (
<div className="space-y-2.5">
<h3 className="flex min-w-0 items-baseline gap-2 text-sm font-medium text-muted-foreground">
<span className="min-w-0 truncate">{label}</span>
<span className="shrink-0 text-xs tabular-nums">{count}</span>
</h3>
{children}
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
"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>
);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* A clickable category in the quiz browser — Books, a book, Lectures, a
* year. Shows how many quizzes it holds and how many are still to take.
*/
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import type { QuizCard } from "@/lib/quiz";
export function QuizCategoryCard({
href,
label,
quizzes,
icon,
}: {
href: string;
label: string;
quizzes: QuizCard[];
icon: React.ReactNode;
}) {
const todo = quizzes.filter((q) => q.my.state !== "completed").length;
const n = quizzes.length;
return (
<Link
href={href}
className="group flex h-full min-w-0 items-center gap-3.5 rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
>
<span className="inline-flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
{icon}
</span>
<span className="min-w-0 flex-1">
<span
title={label}
className="line-clamp-2 font-semibold leading-snug text-foreground [overflow-wrap:anywhere]"
>
{label}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{n} {n === 1 ? "quiz" : "quizzes"}
{todo > 0 && todo < n ? ` · ${todo} to do` : ""}
{todo === 0 ? " · all finished" : ""}
</span>
</span>
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
);
}