fix: ui fixes in transcript lusting for books

This commit is contained in:
gosangam
2026-06-17 14:02:06 +05:30
parent 89f94c1a6e
commit fc2aef0c3d
@@ -38,13 +38,6 @@ export type TranscriptionItem = {
audioUrl: string | null; audioUrl: string | null;
}; };
type YearGroup = {
year: number;
label: string;
count: number;
transcriptions: TranscriptionItem[];
};
type ChapterGroup = { type ChapterGroup = {
key: string; key: string;
label: string; label: string;
@@ -60,48 +53,13 @@ type SectionGroup = {
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Grouping helpers // Grouping
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function buildYearGroups(transcriptions: TranscriptionItem[]): YearGroup[] { // Group all of a book's transcripts into sections (canto/līlā) → chapters,
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 // 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 // into a single section with a null label. Only sections/chapters that have
// transcripts appear. // transcripts appear. Transcripts arrive already sorted chronologically.
function buildSections( function buildSections(
transcriptions: TranscriptionItem[], transcriptions: TranscriptionItem[],
code: string, code: string,
@@ -152,68 +110,93 @@ function buildSections(
})); }));
} }
// --------------------------------------------------------------------------- // Flatten sections into a linear chapter list for prev/next navigation and
// Year Sidebar // active-chapter lookup, carrying each chapter's section label for display.
// --------------------------------------------------------------------------- type FlatChapter = { sectionLabel: string | null; chapter: ChapterGroup };
function YearSidebar({ function flattenChapters(sections: SectionGroup[]): FlatChapter[] {
groups, return sections.flatMap((s) =>
activeYear, s.chapters.map((chapter) => ({ sectionLabel: s.label, chapter })),
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 // Chapter nav list (shared by sidebar + mobile drawer)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function YearMobileNav({ function ChapterNavList({
groups, sections,
activeYear, 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, onSelect,
title, title,
}: { }: {
groups: YearGroup[]; sections: SectionGroup[];
activeYear: number; activeKey: string;
onSelect: (year: number) => void; onSelect: (key: string) => void;
title: string; title: string;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -227,47 +210,22 @@ function YearMobileNav({
)} )}
> >
<Menu className="size-4" /> <Menu className="size-4" />
<span className="hidden sm:inline">Years</span> <span className="hidden sm:inline">Chapters</span>
</DrawerTrigger> </DrawerTrigger>
<DrawerContent direction="left" className="h-full"> <DrawerContent direction="left" className="h-full">
<DrawerHeader className="text-left border-b border-border pb-4 mb-2"> <DrawerHeader className="text-left border-b border-border pb-4 mb-2">
<DrawerTitle>{title}</DrawerTitle> <DrawerTitle>{title}</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<nav className="overflow-y-auto px-4 pb-6 pt-2"> <div className="overflow-y-auto px-4 pb-6 pt-2">
<div className="flex flex-col gap-0.5"> <ChapterNavList
{groups.map((g) => { sections={sections}
const isActive = g.year === activeYear; activeKey={activeKey}
return ( onSelect={(key) => {
<DrawerClose key={g.year} asChild> onSelect(key);
<button setOpen(false);
onClick={() => { }}
onSelect(g.year); />
setOpen(false); </div>
}}
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> </DrawerContent>
</Drawer> </Drawer>
); );
@@ -277,34 +235,34 @@ function YearMobileNav({
// Prev / Next nav // Prev / Next nav
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function YearNav({ function ChapterNav({
prev, prev,
next, next,
groups, sections,
activeYear, activeKey,
onSelect, onSelect,
drawerTitle, drawerTitle,
}: { }: {
prev: YearGroup | null; prev: FlatChapter | null;
next: YearGroup | null; next: FlatChapter | null;
groups: YearGroup[]; sections: SectionGroup[];
activeYear: number; activeKey: string;
onSelect: (year: number) => void; onSelect: (key: string) => void;
drawerTitle: string; drawerTitle: string;
}) { }) {
return ( return (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<YearMobileNav <ChapterMobileNav
groups={groups} sections={sections}
activeYear={activeYear} activeKey={activeKey}
onSelect={onSelect} onSelect={onSelect}
title={drawerTitle} title={drawerTitle}
/> />
{prev ? ( {prev ? (
<button <button
onClick={() => onSelect(prev.year)} onClick={() => onSelect(prev.chapter.key)}
className={cn( className={cn(
buttonVariants({ variant: "outline", size: "sm" }), buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5", "gap-1.5",
@@ -312,7 +270,7 @@ function YearNav({
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]"> <span className="hidden sm:inline truncate max-w-[180px]">
{prev.label} {prev.chapter.label}
</span> </span>
<span className="sm:hidden">Previous</span> <span className="sm:hidden">Previous</span>
</button> </button>
@@ -323,14 +281,14 @@ function YearNav({
{next ? ( {next ? (
<button <button
onClick={() => onSelect(next.year)} onClick={() => onSelect(next.chapter.key)}
className={cn( className={cn(
buttonVariants({ variant: "outline", size: "sm" }), buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5", "gap-1.5",
)} )}
> >
<span className="hidden sm:inline truncate max-w-[180px]"> <span className="hidden sm:inline truncate max-w-[180px]">
{next.label} {next.chapter.label}
</span> </span>
<span className="sm:hidden">Next</span> <span className="sm:hidden">Next</span>
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
@@ -343,101 +301,60 @@ function YearNav({
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Chapter section — collapsible group of transcriptions for a single chapter // Lecture row
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ChapterSection({ function LectureRow({ t }: { t: TranscriptionItem }) {
group,
defaultOpen,
}: {
group: ChapterGroup;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return ( return (
<div> <Link
<button href={`/transcriptions/${t.slug}`}
onClick={() => setOpen(!open)} className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
className="flex items-center gap-3 w-full px-5 py-3 text-left hover:bg-muted/50 transition-colors" >
> <div className="min-w-0 flex-1">
<ChevronRight <div className="flex items-center gap-2 flex-wrap">
className={cn( <p className="font-medium text-foreground text-sm">{t.name}</p>
"size-4 text-muted-foreground shrink-0 transition-transform", <Badge
open && "rotate-90", 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"
/> />
<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> </Link>
); );
} }
@@ -456,45 +373,43 @@ export function TranscriptionBookBrowser({
title: string; title: string;
totalCount: number; totalCount: number;
}) { }) {
const yearGroups = useMemo( const sections = useMemo(
() => buildYearGroups(transcriptions), () => buildSections(transcriptions, code),
[transcriptions], [transcriptions, code],
); );
const [activeYearRaw, setActiveYear] = useState<number>( const flat = useMemo(() => flattenChapters(sections), [sections]);
yearGroups.length > 0 ? yearGroups[0].year : 0,
const [activeKeyRaw, setActiveKey] = useState<string>(
flat.length > 0 ? flat[0].chapter.key : "",
); );
// Derive a safe active year // Derive a safe active chapter key (the data could change underneath us).
const activeYear = useMemo(() => { const activeKey = useMemo(() => {
if (yearGroups.length === 0) return activeYearRaw; if (flat.length === 0) return activeKeyRaw;
if (yearGroups.find((g) => g.year === activeYearRaw)) return activeYearRaw; if (flat.find((f) => f.chapter.key === activeKeyRaw)) return activeKeyRaw;
return yearGroups[0].year; return flat[0].chapter.key;
}, [yearGroups, activeYearRaw]); }, [flat, activeKeyRaw]);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const handleYearSelect = (year: number) => { const handleSelect = (key: string) => {
setActiveYear(year); setActiveKey(key);
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}; };
// Active group + neighbors const activeIdx = flat.findIndex((f) => f.chapter.key === activeKey);
const activeIdx = yearGroups.findIndex((g) => g.year === activeYear); const active = activeIdx >= 0 ? flat[activeIdx] : null;
const activeGroup = activeIdx >= 0 ? yearGroups[activeIdx] : null; const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
const prev = activeIdx > 0 ? yearGroups[activeIdx - 1] : null;
const next = const next =
activeIdx >= 0 && activeIdx < yearGroups.length - 1 activeIdx >= 0 && activeIdx < flat.length - 1 ? flat[activeIdx + 1] : null;
? yearGroups[activeIdx + 1]
: null;
// Sections (canto/līlā) → chapters within the active year const activeFullLabel = active
const sections = useMemo( ? active.sectionLabel
() => buildSections(activeGroup?.transcriptions ?? [], code), ? `${active.sectionLabel} · ${active.chapter.label}`
[activeGroup, code], : active.chapter.label
); : "";
const activeCount = active?.chapter.transcriptions.length ?? 0;
const activeYearLabel = activeGroup?.label ?? "Unknown";
return ( return (
<> <>
@@ -508,79 +423,61 @@ export function TranscriptionBookBrowser({
&larr; Transcriptions by Book &larr; Transcriptions by Book
</Link> </Link>
<h1 className="mt-3 text-2xl font-bold text-foreground"> <h1 className="mt-3 text-2xl font-bold text-foreground">
<span className="text-primary">{title}: </span> <span className="text-primary">{title}</span>
{activeYearLabel} {activeFullLabel ? `: ${activeFullLabel}` : ""}
</h1> </h1>
<p className="mt-1 text-muted-foreground"> <p className="mt-1 text-muted-foreground">
{activeGroup?.count ?? 0}{" "} {activeCount} {activeCount === 1 ? "lecture" : "lectures"} in this
{(activeGroup?.count ?? 0) === 1 chapter · {totalCount} total
? "transcription"
: "transcriptions"}{" "}
in {activeYearLabel} · {totalCount} total
</p> </p>
</div> </div>
</header> </header>
{/* Sidebar + Main */} {/* Sidebar + Main */}
<div className="max-w-5xl mx-auto flex"> <div className="max-w-5xl mx-auto flex">
{/* Left sidebar — years */} {/* Left sidebar — chapters */}
<YearSidebar <ChapterSidebar
groups={yearGroups} sections={sections}
activeYear={activeYear} activeKey={activeKey}
onSelect={handleYearSelect} onSelect={handleSelect}
/> />
{/* Main content */} {/* Main content */}
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8"> <main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */} {/* Top navigation */}
<YearNav <ChapterNav
prev={prev} prev={prev}
next={next} next={next}
groups={yearGroups} sections={sections}
activeYear={activeYear} activeKey={activeKey}
onSelect={handleYearSelect} onSelect={handleSelect}
drawerTitle={title} drawerTitle={title}
/> />
{/* Content — sections (canto/līlā) → chapter accordions */} {/* Content — every lecture of the active chapter (all years/places) */}
{sections.length === 0 ? ( {!active || active.chapter.transcriptions.length === 0 ? (
<div className="py-12 text-center text-muted-foreground"> <div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium"> <p className="text-lg font-medium">
No transcriptions for this year No transcriptions for this chapter
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-12"> <Card className="overflow-hidden">
{sections.map((section) => ( <CardContent className="p-0 divide-y divide-border">
<section key={section.key} className="space-y-3"> {active.chapter.transcriptions.map((t) => (
{section.label && ( <LectureRow key={t.slug} t={t} />
<h2 className="text-lg font-bold text-foreground border-b border-border pb-2"> ))}
{section.label} </CardContent>
</h2> </Card>
)}
<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 */} {/* Bottom navigation */}
<YearNav <ChapterNav
prev={prev} prev={prev}
next={next} next={next}
groups={yearGroups} sections={sections}
activeYear={activeYear} activeKey={activeKey}
onSelect={handleYearSelect} onSelect={handleSelect}
drawerTitle={title} drawerTitle={title}
/> />
</main> </main>