diff --git a/app/(app)/quizzes/page.tsx b/app/(app)/quizzes/page.tsx index e894215f..72e38fd8 100644 --- a/app/(app)/quizzes/page.tsx +++ b/app/(app)/quizzes/page.tsx @@ -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 ; + // The list reads its tab and category from the URL (useSearchParams), + // which needs a Suspense boundary to render. + return ( + + + + ); } diff --git a/app/(app)/quizzes/quizzes-list.tsx b/app/(app)/quizzes/quizzes-list.tsx index a35f03bc..029b2a32 100644 --- a/app/(app)/quizzes/quizzes-list.tsx +++ b/app/(app)/quizzes/quizzes-list.tsx @@ -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(null); - const [filter, setFilter] = useState("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" ? ( ) : ( - + )} ); diff --git a/components/quiz/grouped-quizzes.tsx b/components/quiz/grouped-quizzes.tsx deleted file mode 100644 index 54c2ba7b..00000000 --- a/components/quiz/grouped-quizzes.tsx +++ /dev/null @@ -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 ( -
- {sections.books.length > 0 && ( -
- {sections.books.map((g) => ( - - - - ))} -
- )} - {sections.lectures.length > 0 && ( -
- {sections.lectures.map((g) => ( - - - - ))} -
- )} - {sections.other.length > 0 && ( -
- -
- )} -
- ); -} - -function Section({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-

- {title} -

- {children} -
- ); -} - -function Group({ - label, - count, - children, -}: { - label: string; - count: number; - children: React.ReactNode; -}) { - return ( -
-

- {label} - {count} -

- {children} -
- ); -} diff --git a/components/quiz/quiz-browser.tsx b/components/quiz/quiz-browser.tsx new file mode 100644 index 00000000..298d90db --- /dev/null +++ b/components/quiz/quiz-browser.tsx @@ -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 ( + + + + ); + } + // Lectures → a year: its quizzes. + if (year) { + return ( + + + + ); + } + // Books: a card per book. + if (path.in === BOOKS && books.length > 0) { + return ( + + + {books.map((g) => ( + } + /> + ))} + + + ); + } + // Lectures: a card per year. + if (path.in === LECTURES && lectures.length > 0) { + return ( + + + {lectures.map((g) => ( + } + /> + ))} + + + ); + } + // Stand-alone quizzes. + if (path.in === OTHER && other.length > 0) { + return ( + + + + ); + } + + // Top level: one card per category that has quizzes. + return ( + + {books.length > 0 && ( + } + /> + )} + {lectures.length > 0 && ( + } + /> + )} + {other.length > 0 && ( + } + /> + )} + + ); +} + +function CardGrid({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** 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 ( +
+ + {children} +
+ ); +} diff --git a/components/quiz/quiz-category-card.tsx b/components/quiz/quiz-category-card.tsx new file mode 100644 index 00000000..8f0af7f6 --- /dev/null +++ b/components/quiz/quiz-category-card.tsx @@ -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 ( + + + {icon} + + + + {label} + + + {n} {n === 1 ? "quiz" : "quizzes"} + {todo > 0 && todo < n ? ` · ${todo} to do` : ""} + {todo === 0 ? " · all finished" : ""} + + + + + ); +} diff --git a/lib/quiz-groups.ts b/lib/quiz-groups.ts index 596bdbf5..c801471f 100644 --- a/lib/quiz-groups.ts +++ b/lib/quiz-groups.ts @@ -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.