quiz grouping
This commit is contained in:
@@ -9,19 +9,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { ClipboardCheck } from "lucide-react";
|
|
||||||
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
|
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
|
||||||
import { quiz, quizActionLabel, type QuizCard } from "@/lib/quiz";
|
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 { cn } from "@/lib/utils";
|
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";
|
type Filter = "all" | "todo" | "done";
|
||||||
|
|
||||||
export function QuizzesList() {
|
export function QuizzesList() {
|
||||||
@@ -102,52 +96,10 @@ export function QuizzesList() {
|
|||||||
? "No quizzes yet — check back soon."
|
? "No quizzes yet — check back soon."
|
||||||
: "Nothing here."}
|
: "Nothing here."}
|
||||||
</p>
|
</p>
|
||||||
|
) : filter !== "all" ? (
|
||||||
|
<QuizGrid quizzes={shown} />
|
||||||
) : (
|
) : (
|
||||||
// minmax(0,1fr) + min-w-0: grid items otherwise refuse to shrink
|
<GroupedQuizzes sections={groupQuizzes(shown)} />
|
||||||
// below their content, so one long word pushes a card off-screen.
|
|
||||||
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
{shown.map((q) => {
|
|
||||||
const done = q.my.state === "completed";
|
|
||||||
return (
|
|
||||||
<li key={q.id} className="min-w-0">
|
|
||||||
<Link
|
|
||||||
href={`/quizzes/${q.id}`}
|
|
||||||
className="flex h-full min-w-0 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 break-words font-semibold leading-snug text-foreground [overflow-wrap:anywhere]">
|
|
||||||
{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 flex-wrap items-center justify-between gap-x-3 gap-y-1 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 whitespace-nowrap 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>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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,19 @@
|
|||||||
|
import type { QuizCard } from "@/lib/quiz";
|
||||||
|
import { QuizListCard } from "./quiz-list-card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quiz cards, two across from sm up. minmax(0,1fr) + min-w-0: grid items
|
||||||
|
* otherwise refuse to shrink below their content, so one long word pushes a
|
||||||
|
* card off-screen.
|
||||||
|
*/
|
||||||
|
export function QuizGrid({ quizzes }: { quizzes: QuizCard[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{quizzes.map((q) => (
|
||||||
|
<li key={q.id} className="min-w-0">
|
||||||
|
<QuizListCard q={q} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** One quiz in the /quizzes list: what it's on, its title, and your progress. */
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ClipboardCheck } from "lucide-react";
|
||||||
|
import { 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuizListCard({ q }: { q: QuizCard }) {
|
||||||
|
const done = q.my.state === "completed";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/quizzes/${q.id}`}
|
||||||
|
className="flex h-full min-w-0 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 break-words font-semibold leading-snug text-foreground [overflow-wrap:anywhere]">
|
||||||
|
{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 flex-wrap items-center justify-between gap-x-3 gap-y-1 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 whitespace-nowrap 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Groups the quiz list for the "All" tab:
|
||||||
|
*
|
||||||
|
* Books → one group per book, in the library's order
|
||||||
|
* Lectures & Conversations → one group per year, oldest first, undated last
|
||||||
|
* Other quizzes → stand-alone quizzes
|
||||||
|
*
|
||||||
|
* Only groups with quizzes in them are returned, so an empty category or
|
||||||
|
* sub-category never renders.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BOOK_NAMES } from "@/lib/constants";
|
||||||
|
import type { QuizCard } from "@/lib/quiz";
|
||||||
|
|
||||||
|
export type QuizGroup = { key: string; label: string; quizzes: QuizCard[] };
|
||||||
|
|
||||||
|
export type QuizSections = {
|
||||||
|
books: QuizGroup[];
|
||||||
|
lectures: QuizGroup[];
|
||||||
|
other: QuizCard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOOK_ORDER = Object.keys(BOOK_NAMES);
|
||||||
|
|
||||||
|
/** "bg" from /books/bg/2 — the book a chapter quiz belongs to. */
|
||||||
|
function bookCode(path?: string | null): string | null {
|
||||||
|
const m = path?.match(/^\/books\/([^/]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recording's year. Saved on the quiz since target_year existed; for
|
||||||
|
* older quizzes, read from the slug's yymmdd prefix (720720sb-london → 1972)
|
||||||
|
* when it falls in Śrīla Prabhupāda's recorded years.
|
||||||
|
*/
|
||||||
|
function lectureYear(q: QuizCard): number | null {
|
||||||
|
if (q.target_year) return q.target_year;
|
||||||
|
const m = q.target_path?.match(/^\/transcriptions\/(\d{2})\d{4}/);
|
||||||
|
if (!m) return null;
|
||||||
|
const y = 1900 + Number(m[1]);
|
||||||
|
return y >= 1966 && y <= 1977 ? y : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupQuizzes(cards: QuizCard[]): QuizSections {
|
||||||
|
const books = new Map<string, QuizCard[]>();
|
||||||
|
const years = new Map<number | null, QuizCard[]>();
|
||||||
|
const other: QuizCard[] = [];
|
||||||
|
|
||||||
|
for (const q of cards) {
|
||||||
|
if (q.target_kind === "chapter") {
|
||||||
|
const code = bookCode(q.target_path) ?? "other";
|
||||||
|
books.set(code, [...(books.get(code) ?? []), q]);
|
||||||
|
} else if (
|
||||||
|
q.target_kind === "lecture" ||
|
||||||
|
q.target_kind === "conversation"
|
||||||
|
) {
|
||||||
|
const y = lectureYear(q);
|
||||||
|
years.set(y, [...(years.get(y) ?? []), q]);
|
||||||
|
} else {
|
||||||
|
other.push(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = (code: string) => {
|
||||||
|
const i = BOOK_ORDER.indexOf(code);
|
||||||
|
return i < 0 ? BOOK_ORDER.length : i;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
books: [...books.entries()]
|
||||||
|
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||||
|
.map(([code, quizzes]) => ({
|
||||||
|
key: code,
|
||||||
|
label: BOOK_NAMES[code] ?? code.toUpperCase(),
|
||||||
|
quizzes,
|
||||||
|
})),
|
||||||
|
lectures: [...years.entries()]
|
||||||
|
// Oldest first; undated (null) last.
|
||||||
|
.sort(([a], [b]) => (a ?? 9999) - (b ?? 9999))
|
||||||
|
.map(([year, quizzes]) => ({
|
||||||
|
key: String(year ?? "undated"),
|
||||||
|
label: year ? String(year) : "Undated",
|
||||||
|
quizzes,
|
||||||
|
})),
|
||||||
|
other,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ export type QuizCard = {
|
|||||||
target_kind: "chapter" | "lecture" | "conversation" | "standalone";
|
target_kind: "chapter" | "lecture" | "conversation" | "standalone";
|
||||||
target_path?: string | null;
|
target_path?: string | null;
|
||||||
target_title: string;
|
target_title: string;
|
||||||
|
/** Year of the lecture / conversation, when known. */
|
||||||
|
target_year?: number | null;
|
||||||
question_count: number;
|
question_count: number;
|
||||||
my: { state: QuizState; answered: number; total: number; score?: number };
|
my: { state: QuizState; answered: number; total: number; score?: number };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user