feat: add book transcripts categorisation
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight, Menu, Calendar, Clock, MapPin } from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/(app)/transcriptions/[slug]/play-audio-button";
|
||||
import { parseChapter } from "@/lib/book-reference";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranscriptionItem = {
|
||||
slug: string;
|
||||
name: string;
|
||||
type: string;
|
||||
date: string | null;
|
||||
year: number | null;
|
||||
month: number | null;
|
||||
day: number | null;
|
||||
place: string | null;
|
||||
context: string | null;
|
||||
reference: string | null;
|
||||
durationMinutes: number | null;
|
||||
participants: string[];
|
||||
audioUrl: string | null;
|
||||
};
|
||||
|
||||
type YearGroup = {
|
||||
year: number;
|
||||
label: string;
|
||||
count: number;
|
||||
transcriptions: TranscriptionItem[];
|
||||
};
|
||||
|
||||
type ChapterGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
transcriptions: TranscriptionItem[];
|
||||
};
|
||||
|
||||
// A section is a canto (SB) or līlā (CC). Books without that level produce a
|
||||
// single section with a null label (rendered without a heading).
|
||||
type SectionGroup = {
|
||||
key: string;
|
||||
label: string | null;
|
||||
chapters: ChapterGroup[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grouping helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildYearGroups(transcriptions: TranscriptionItem[]): YearGroup[] {
|
||||
const map = new Map<number, TranscriptionItem[]>();
|
||||
|
||||
for (const t of transcriptions) {
|
||||
const y = t.year ?? 0;
|
||||
if (!map.has(y)) map.set(y, []);
|
||||
map.get(y)!.push(t);
|
||||
}
|
||||
|
||||
const groups: YearGroup[] = [];
|
||||
|
||||
const years = Array.from(map.keys()).sort((a, b) => a - b);
|
||||
for (const y of years) {
|
||||
if (y === 0) continue;
|
||||
const items = map.get(y)!;
|
||||
groups.push({
|
||||
year: y,
|
||||
label: String(y),
|
||||
count: items.length,
|
||||
transcriptions: items,
|
||||
});
|
||||
}
|
||||
const undated = map.get(0);
|
||||
if (undated && undated.length > 0) {
|
||||
groups.push({
|
||||
year: 0,
|
||||
label: "Undated",
|
||||
count: undated.length,
|
||||
transcriptions: undated,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Group a year's transcripts into sections (canto/līlā) → chapter accordions,
|
||||
// ordered by the parsed sort tuple. Books without a canto/līlā level collapse
|
||||
// into a single section with a null label. Only sections/chapters that have
|
||||
// transcripts appear.
|
||||
function buildSections(
|
||||
transcriptions: TranscriptionItem[],
|
||||
code: string,
|
||||
): SectionGroup[] {
|
||||
const sections = new Map<
|
||||
string,
|
||||
{
|
||||
label: string | null;
|
||||
order: number;
|
||||
chapters: Map<
|
||||
string,
|
||||
{ label: string; sort: [number, number]; items: TranscriptionItem[] }
|
||||
>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const t of transcriptions) {
|
||||
const ch = parseChapter(code, t.reference);
|
||||
const secKey = ch.section ? ch.section.key : "__flat__";
|
||||
if (!sections.has(secKey)) {
|
||||
sections.set(secKey, {
|
||||
label: ch.section ? ch.section.label : null,
|
||||
order: ch.section ? ch.section.order : 0,
|
||||
chapters: new Map(),
|
||||
});
|
||||
}
|
||||
const sec = sections.get(secKey)!;
|
||||
if (!sec.chapters.has(ch.key)) {
|
||||
sec.chapters.set(ch.key, { label: ch.label, sort: ch.sort, items: [] });
|
||||
}
|
||||
sec.chapters.get(ch.key)!.items.push(t);
|
||||
}
|
||||
|
||||
return Array.from(sections.entries())
|
||||
.sort((a, b) => a[1].order - b[1].order)
|
||||
.map(([key, s]) => ({
|
||||
key,
|
||||
label: s.label,
|
||||
chapters: Array.from(s.chapters.entries())
|
||||
.sort(
|
||||
(a, b) => a[1].sort[0] - b[1].sort[0] || a[1].sort[1] - b[1].sort[1],
|
||||
)
|
||||
.map(([k, v]) => ({
|
||||
key: k,
|
||||
label: v.label,
|
||||
transcriptions: v.items,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Year Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function YearSidebar({
|
||||
groups,
|
||||
activeYear,
|
||||
onSelect,
|
||||
}: {
|
||||
groups: YearGroup[];
|
||||
activeYear: number;
|
||||
onSelect: (year: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Years
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{groups.map((g) => {
|
||||
const isActive = g.year === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={g.year}
|
||||
onClick={() => onSelect(g.year)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-2 py-1.5 rounded-md text-sm transition-colors text-left",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{g.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums shrink-0",
|
||||
isActive ? "text-primary/70" : "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{g.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function YearMobileNav({
|
||||
groups,
|
||||
activeYear,
|
||||
onSelect,
|
||||
title,
|
||||
}: {
|
||||
groups: YearGroup[];
|
||||
activeYear: number;
|
||||
onSelect: (year: number) => void;
|
||||
title: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
||||
<DrawerTrigger
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"lg:hidden gap-1.5",
|
||||
)}
|
||||
>
|
||||
<Menu className="size-4" />
|
||||
<span className="hidden sm:inline">Years</span>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent direction="left" className="h-full">
|
||||
<DrawerHeader className="text-left border-b border-border pb-4 mb-2">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<nav className="overflow-y-auto px-4 pb-6 pt-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groups.map((g) => {
|
||||
const isActive = g.year === activeYear;
|
||||
return (
|
||||
<DrawerClose key={g.year} asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelect(g.year);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-2 py-2 rounded-md text-sm transition-colors text-left",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{g.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isActive
|
||||
? "text-primary/70"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{g.count}
|
||||
</span>
|
||||
</button>
|
||||
</DrawerClose>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next nav
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function YearNav({
|
||||
prev,
|
||||
next,
|
||||
groups,
|
||||
activeYear,
|
||||
onSelect,
|
||||
drawerTitle,
|
||||
}: {
|
||||
prev: YearGroup | null;
|
||||
next: YearGroup | null;
|
||||
groups: YearGroup[];
|
||||
activeYear: number;
|
||||
onSelect: (year: number) => void;
|
||||
drawerTitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<YearMobileNav
|
||||
groups={groups}
|
||||
activeYear={activeYear}
|
||||
onSelect={onSelect}
|
||||
title={drawerTitle}
|
||||
/>
|
||||
|
||||
{prev ? (
|
||||
<button
|
||||
onClick={() => onSelect(prev.year)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<button
|
||||
onClick={() => onSelect(next.year)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapter section — collapsible group of transcriptions for a single chapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChapterSection({
|
||||
group,
|
||||
defaultOpen,
|
||||
}: {
|
||||
group: ChapterGroup;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-3 w-full px-5 py-3 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground shrink-0 transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="font-semibold text-foreground text-sm">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{group.transcriptions.length}{" "}
|
||||
{group.transcriptions.length === 1
|
||||
? "transcription"
|
||||
: "transcriptions"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul className="divide-y divide-border">
|
||||
{group.transcriptions.map((t) => (
|
||||
<li key={t.slug}>
|
||||
<Link
|
||||
href={`/transcriptions/${t.slug}`}
|
||||
className="flex items-center px-5 pl-12 py-3.5 gap-3 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{t.name}
|
||||
</p>
|
||||
<Badge
|
||||
variant={t.type === "lecture" ? "default" : "secondary"}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{t.type === "lecture" ? "Lecture" : "Conversation"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
|
||||
{t.date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" />
|
||||
{t.date}
|
||||
</span>
|
||||
)}
|
||||
{t.place && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
{t.place}
|
||||
</span>
|
||||
)}
|
||||
{t.reference && (
|
||||
<span className="text-primary/70">{t.reference}</span>
|
||||
)}
|
||||
{t.durationMinutes && t.durationMinutes > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{t.durationMinutes} min
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t.audioUrl && (
|
||||
<PlayAudioButton
|
||||
audioUrl={t.audioUrl}
|
||||
title={t.name}
|
||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||
sourceUrl={`/transcriptions/${t.slug}`}
|
||||
type={t.type}
|
||||
variant="compact"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TranscriptionBookBrowser({
|
||||
transcriptions,
|
||||
code,
|
||||
title,
|
||||
totalCount,
|
||||
}: {
|
||||
transcriptions: TranscriptionItem[];
|
||||
code: string;
|
||||
title: string;
|
||||
totalCount: number;
|
||||
}) {
|
||||
const yearGroups = useMemo(
|
||||
() => buildYearGroups(transcriptions),
|
||||
[transcriptions],
|
||||
);
|
||||
|
||||
const [activeYearRaw, setActiveYear] = useState<number>(
|
||||
yearGroups.length > 0 ? yearGroups[0].year : 0,
|
||||
);
|
||||
|
||||
// Derive a safe active year
|
||||
const activeYear = useMemo(() => {
|
||||
if (yearGroups.length === 0) return activeYearRaw;
|
||||
if (yearGroups.find((g) => g.year === activeYearRaw)) return activeYearRaw;
|
||||
return yearGroups[0].year;
|
||||
}, [yearGroups, activeYearRaw]);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleYearSelect = (year: number) => {
|
||||
setActiveYear(year);
|
||||
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
// Active group + neighbors
|
||||
const activeIdx = yearGroups.findIndex((g) => g.year === activeYear);
|
||||
const activeGroup = activeIdx >= 0 ? yearGroups[activeIdx] : null;
|
||||
const prev = activeIdx > 0 ? yearGroups[activeIdx - 1] : null;
|
||||
const next =
|
||||
activeIdx >= 0 && activeIdx < yearGroups.length - 1
|
||||
? yearGroups[activeIdx + 1]
|
||||
: null;
|
||||
|
||||
// Sections (canto/līlā) → chapters within the active year
|
||||
const sections = useMemo(
|
||||
() => buildSections(activeGroup?.transcriptions ?? [], code),
|
||||
[activeGroup, code],
|
||||
);
|
||||
|
||||
const activeYearLabel = activeGroup?.label ?? "Unknown";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Link
|
||||
href="/transcriptions?view=book"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Transcriptions by Book
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">{title}: </span>
|
||||
{activeYearLabel}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{activeGroup?.count ?? 0}{" "}
|
||||
{(activeGroup?.count ?? 0) === 1
|
||||
? "transcription"
|
||||
: "transcriptions"}{" "}
|
||||
in {activeYearLabel} · {totalCount} total
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sidebar + Main */}
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar — years */}
|
||||
<YearSidebar
|
||||
groups={yearGroups}
|
||||
activeYear={activeYear}
|
||||
onSelect={handleYearSelect}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<YearNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
groups={yearGroups}
|
||||
activeYear={activeYear}
|
||||
onSelect={handleYearSelect}
|
||||
drawerTitle={title}
|
||||
/>
|
||||
|
||||
{/* Content — sections (canto/līlā) → chapter accordions */}
|
||||
{sections.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<p className="text-lg font-medium">
|
||||
No transcriptions for this year
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{sections.map((section) => (
|
||||
<section key={section.key} className="space-y-3">
|
||||
{section.label && (
|
||||
<h2 className="text-lg font-bold text-foreground border-b border-border pb-2">
|
||||
{section.label}
|
||||
</h2>
|
||||
)}
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0 divide-y divide-border">
|
||||
{section.chapters.map((cg, i) => (
|
||||
<ChapterSection
|
||||
key={cg.key}
|
||||
group={cg}
|
||||
defaultOpen={i === 0 || section.chapters.length <= 3}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<YearNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
groups={yearGroups}
|
||||
activeYear={activeYear}
|
||||
onSelect={handleYearSelect}
|
||||
drawerTitle={title}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user