Files
spsays-training/app/(app)/transcriptions/book/[book]/book-browser.tsx
T

507 lines
16 KiB
TypeScript

"use client";
import { useState, useMemo, useRef } from "react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
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, parseVerse } 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 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
// ---------------------------------------------------------------------------
// Group all of a book's transcripts into sections (canto/līlā) → chapters,
// 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. Transcripts arrive already sorted chronologically.
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,
// Order lectures by verse ascending (e.g. 2.1, 2.3, 2.4…),
// irrespective of year. Stable sort keeps year order within a verse.
transcriptions: v.items
.slice()
.sort((a, b) => parseVerse(a.reference) - parseVerse(b.reference)),
})),
}));
}
// Flatten sections into a linear chapter list for prev/next navigation and
// active-chapter lookup, carrying each chapter's section label for display.
type FlatChapter = { sectionLabel: string | null; chapter: ChapterGroup };
function flattenChapters(sections: SectionGroup[]): FlatChapter[] {
return sections.flatMap((s) =>
s.chapters.map((chapter) => ({ sectionLabel: s.label, chapter })),
);
}
// ---------------------------------------------------------------------------
// Chapter nav list (shared by sidebar + mobile drawer)
// ---------------------------------------------------------------------------
function ChapterNavList({
sections,
activeKey,
onSelect,
}: {
sections: SectionGroup[];
activeKey: string;
onSelect: (key: string) => void;
}) {
return (
<nav className="flex flex-col gap-3">
{sections.map((section) => (
<div key={section.key} className="flex flex-col gap-0.5">
{section.label && (
<div className="px-2 pt-1 pb-0.5 text-xs font-bold uppercase tracking-wider text-muted-foreground/80">
{section.label}
</div>
)}
{section.chapters.map((ch) => {
const isActive = ch.key === activeKey;
return (
<button
key={ch.key}
onClick={() => onSelect(ch.key)}
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">{ch.label}</span>
<span
className={cn(
"text-xs tabular-nums shrink-0",
isActive ? "text-primary/70" : "text-muted-foreground/60",
)}
>
{ch.transcriptions.length}
</span>
</button>
);
})}
</div>
))}
</nav>
);
}
function ChapterSidebar(props: {
sections: SectionGroup[];
activeKey: string;
onSelect: (key: string) => void;
}) {
return (
<aside className="hidden lg:block w-60 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">
Chapters
</h2>
<ChapterNavList {...props} />
</aside>
);
}
function ChapterMobileNav({
sections,
activeKey,
onSelect,
title,
}: {
sections: SectionGroup[];
activeKey: string;
onSelect: (key: string) => 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">Chapters</span>
</DrawerTrigger>
<DrawerContent direction="left" className="h-full">
<DrawerHeader className="text-left border-b border-border pb-4 mb-2">
<DrawerTitle>{title}</DrawerTitle>
</DrawerHeader>
<div className="overflow-y-auto px-4 pb-6 pt-2">
<ChapterNavList
sections={sections}
activeKey={activeKey}
onSelect={(key) => {
onSelect(key);
setOpen(false);
}}
/>
</div>
</DrawerContent>
</Drawer>
);
}
// ---------------------------------------------------------------------------
// Prev / Next nav
// ---------------------------------------------------------------------------
function ChapterNav({
prev,
next,
sections,
activeKey,
onSelect,
drawerTitle,
}: {
prev: FlatChapter | null;
next: FlatChapter | null;
sections: SectionGroup[];
activeKey: string;
onSelect: (key: string) => void;
drawerTitle: string;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ChapterMobileNav
sections={sections}
activeKey={activeKey}
onSelect={onSelect}
title={drawerTitle}
/>
{prev ? (
<button
onClick={() => onSelect(prev.chapter.key)}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.chapter.label}
</span>
<span className="sm:hidden">Previous</span>
</button>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<button
onClick={() => onSelect(next.chapter.key)}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.chapter.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</button>
) : (
<div />
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Lecture row
// ---------------------------------------------------------------------------
function LectureRow({ t }: { t: TranscriptionItem }) {
return (
<Link
href={`/transcriptions/${t.slug}`}
className="flex items-center px-5 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>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function TranscriptionBookBrowser({
transcriptions,
code,
title,
totalCount,
}: {
transcriptions: TranscriptionItem[];
code: string;
title: string;
totalCount: number;
}) {
const sections = useMemo(
() => buildSections(transcriptions, code),
[transcriptions, code],
);
const flat = useMemo(() => flattenChapters(sections), [sections]);
// The active chapter lives in the URL (?chapter=<key>) so it's bookmarkable
// and survives back-navigation: opening a lecture then pressing Back returns
// to the chapter you were on rather than resetting to the first one. We seed
// local state from the URL on mount and keep the URL in sync on selection.
const pathname = usePathname();
const searchParams = useSearchParams();
const [activeKeyRaw, setActiveKey] = useState<string>(() => {
const fromUrl = searchParams.get("chapter");
if (fromUrl && flat.find((f) => f.chapter.key === fromUrl)) return fromUrl;
return flat.length > 0 ? flat[0].chapter.key : "";
});
// Derive a safe active chapter key (the data could change underneath us).
const activeKey = useMemo(() => {
if (flat.length === 0) return activeKeyRaw;
if (flat.find((f) => f.chapter.key === activeKeyRaw)) return activeKeyRaw;
return flat[0].chapter.key;
}, [flat, activeKeyRaw]);
const contentRef = useRef<HTMLDivElement>(null);
const handleSelect = (key: string) => {
setActiveKey(key);
// Reflect the choice in the URL without a new history entry (so Back leaves
// the page rather than cycling chapters) or a server round-trip.
const params = new URLSearchParams(Array.from(searchParams.entries()));
params.set("chapter", key);
window.history.replaceState(null, "", `${pathname}?${params.toString()}`);
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const activeIdx = flat.findIndex((f) => f.chapter.key === activeKey);
const active = activeIdx >= 0 ? flat[activeIdx] : null;
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
const next =
activeIdx >= 0 && activeIdx < flat.length - 1 ? flat[activeIdx + 1] : null;
const activeFullLabel = active
? active.sectionLabel
? `${active.sectionLabel} · ${active.chapter.label}`
: active.chapter.label
: "";
const activeCount = active?.chapter.transcriptions.length ?? 0;
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"
>
&larr; Transcriptions by Book
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
<span className="text-primary">{title}</span>
{activeFullLabel ? `: ${activeFullLabel}` : ""}
</h1>
<p className="mt-1 text-muted-foreground">
{activeCount} {activeCount === 1 ? "lecture" : "lectures"} in this
chapter · {totalCount} total
</p>
</div>
</header>
{/* Sidebar + Main */}
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar — chapters */}
<ChapterSidebar
sections={sections}
activeKey={activeKey}
onSelect={handleSelect}
/>
{/* Main content */}
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<ChapterNav
prev={prev}
next={next}
sections={sections}
activeKey={activeKey}
onSelect={handleSelect}
drawerTitle={title}
/>
{/* Content — every lecture of the active chapter (all years/places) */}
{!active || active.chapter.transcriptions.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">
No transcriptions for this chapter
</p>
</div>
) : (
<Card className="overflow-hidden">
<CardContent className="p-0 divide-y divide-border">
{active.chapter.transcriptions.map((t) => (
<LectureRow key={t.slug} t={t} />
))}
</CardContent>
</Card>
)}
{/* Bottom navigation */}
<ChapterNav
prev={prev}
next={next}
sections={sections}
activeKey={activeKey}
onSelect={handleSelect}
drawerTitle={title}
/>
</main>
</div>
</>
);
}