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:
co-authored by
Claude Opus 5.5
parent
3af4e1bcd3
commit
0a23ab59f7
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ogForQuizzes } from "@/lib/og";
|
||||
import { QuizzesList } from "./quizzes-list";
|
||||
@@ -10,5 +11,11 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function QuizzesPage() {
|
||||
return <QuizzesList />;
|
||||
// The list reads its tab and category from the URL (useSearchParams),
|
||||
// which needs a Suspense boundary to render.
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<QuizzesList />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,23 +6,37 @@
|
||||
* 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.
|
||||
* Signed in only: visitors get the sign-in modal, and home if they dismiss it.
|
||||
*
|
||||
* The tab (?tab=todo) and, under All, the category you've drilled into
|
||||
* (?in=books&book=bg) both live in the URL, so a refresh keeps your place.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
|
||||
import { quiz, type QuizCard } from "@/lib/quiz";
|
||||
import { groupQuizzes } from "@/lib/quiz-groups";
|
||||
import { QuizGrid } from "@/components/quiz/quiz-grid";
|
||||
import { GroupedQuizzes } from "@/components/quiz/grouped-quizzes";
|
||||
import { QuizBrowser } from "@/components/quiz/quiz-browser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Filter = "all" | "todo" | "done";
|
||||
const FILTERS: readonly Filter[] = ["all", "todo", "done"];
|
||||
|
||||
export function QuizzesList() {
|
||||
const { user } = useSignedInPage();
|
||||
const userId = user?.id;
|
||||
const [list, setList] = useState<QuizCard[] | null>(null);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const tabParam = params.get("tab") as Filter | null;
|
||||
const filter: Filter =
|
||||
tabParam && FILTERS.includes(tabParam) ? tabParam : "all";
|
||||
// Switching tab starts that tab at its top; All is the bare URL.
|
||||
const setFilter = (f: Filter) =>
|
||||
router.replace(f === "all" ? pathname : `${pathname}?tab=${f}`, {
|
||||
scroll: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
@@ -99,7 +113,14 @@ export function QuizzesList() {
|
||||
) : filter !== "all" ? (
|
||||
<QuizGrid quizzes={shown} />
|
||||
) : (
|
||||
<GroupedQuizzes sections={groupQuizzes(shown)} />
|
||||
<QuizBrowser
|
||||
quizzes={shown}
|
||||
path={{
|
||||
in: params.get("in"),
|
||||
book: params.get("book"),
|
||||
year: params.get("year"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+6
-1
@@ -72,7 +72,12 @@ export function groupQuizzes(cards: QuizCard[]): QuizSections {
|
||||
.map(([code, quizzes]) => ({
|
||||
key: code,
|
||||
label: BOOK_NAMES[code] ?? code.toUpperCase(),
|
||||
quizzes,
|
||||
// Chapter order (/books/bg/2 before /books/bg/10), not newest first.
|
||||
quizzes: [...quizzes].sort((a, b) =>
|
||||
(a.target_path ?? "").localeCompare(b.target_path ?? "", undefined, {
|
||||
numeric: true,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
lectures: [...years.entries()]
|
||||
// Oldest first; undated (null) last.
|
||||
|
||||
Reference in New Issue
Block a user