feat: add book transcripts categorisation

This commit is contained in:
gosangam
2026-06-17 12:40:40 +05:30
parent f96e7dd2f1
commit f5abe33c09
7 changed files with 930 additions and 10 deletions
@@ -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"
>
&larr; 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>
</>
);
}
@@ -0,0 +1,49 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranscriptionsByBook } from "@/lib/transcriptions";
import { bookTitle } from "@/lib/book-reference";
import { buildOgMeta } from "@/lib/og";
import { TranscriptionBookBrowser } from "./book-browser";
export async function generateMetadata({
params,
}: {
params: Promise<{ book: string }>;
}): Promise<Metadata> {
const { book } = await params;
const title = bookTitle(book);
if (!title) return {};
const transcriptions = await getTranscriptionsByBook(book.toUpperCase());
return buildOgMeta({
title: "Transcriptions",
detail: `${transcriptions.length} lectures on ${title}`,
quote: `Śrīla Prabhupāda's lectures on ${title}, browsable by chapter — the living voice of the Founder-Ācārya preserved in text.`,
});
}
export default async function TranscriptionsByBookPage({
params,
}: {
params: Promise<{ book: string }>;
}) {
const { book } = await params;
const code = book.toUpperCase();
const title = bookTitle(code);
if (!title) notFound();
const transcriptions = await getTranscriptionsByBook(code);
if (transcriptions.length === 0) notFound();
return (
<div className="min-h-screen bg-background">
<TranscriptionBookBrowser
transcriptions={transcriptions}
code={code}
title={title}
totalCount={transcriptions.length}
/>
</div>
);
}
+4 -1
View File
@@ -7,6 +7,7 @@ import { ogForTranscriptions } from "@/lib/og";
import {
getTranscriptionYears,
getTranscriptionPlaces,
getTranscriptionBooks,
getTotalTranscriptionCount,
getTranscriptionIndex,
} from "@/lib/transcriptions";
@@ -24,9 +25,10 @@ export default async function TranscriptionsPage({
}) {
const { view = "year" } = await searchParams;
const [years, places, totalCount, index] = await Promise.all([
const [years, places, books, totalCount, index] = await Promise.all([
getTranscriptionYears(),
getTranscriptionPlaces(),
getTranscriptionBooks(),
getTotalTranscriptionCount(),
getTranscriptionIndex(),
]);
@@ -56,6 +58,7 @@ export default async function TranscriptionsPage({
currentView={view}
years={years}
places={places}
books={books}
lectureCount={index.typeDistribution.lecture}
conversationCount={index.typeDistribution.conversation}
/>
@@ -24,6 +24,7 @@ import {
export type YearGroup = { year: number; count: number };
export type PlaceGroup = { name: string; count: number };
export type BookGroup = { code: string; title: string; count: number };
type SortMode = "count" | "asc" | "desc";
@@ -59,14 +60,14 @@ function normalize(text: string): string {
// ---------------------------------------------------------------------------
const SORT_OPTIONS: { key: SortMode; label: string; icon: typeof Hash }[] = [
{ key: "count", label: "Most transcriptions first", icon: Hash },
{ key: "count", label: "Most transcripts first", icon: Hash },
{ key: "asc", label: "A → Z", icon: ArrowDownAZ },
{ key: "desc", label: "Z → A", icon: ArrowUpAZ },
];
const SORT_OPTIONS_YEAR: { key: SortMode; label: string; icon: typeof Hash }[] =
[
{ key: "count", label: "Most transcriptions first", icon: Hash },
{ key: "count", label: "Most transcripts first", icon: Hash },
{ key: "asc", label: "Oldest first", icon: ArrowDownAZ },
{ key: "desc", label: "Newest first", icon: ArrowUpAZ },
];
@@ -150,6 +151,7 @@ function SortPopover({
const PAGE_SIZE_PLACES = 60;
const PAGE_SIZE_YEARS = 100;
const PAGE_SIZE_BOOKS = 100;
function Pagination({
page,
@@ -227,6 +229,18 @@ function sortPlaces(data: PlaceGroup[], mode: SortMode): PlaceGroup[] {
}
}
function sortBooks(data: BookGroup[], mode: SortMode): BookGroup[] {
const sorted = [...data];
switch (mode) {
case "count":
return sorted.sort((a, b) => b.count - a.count);
case "asc":
return sorted.sort((a, b) => a.title.localeCompare(b.title));
case "desc":
return sorted.sort((a, b) => b.title.localeCompare(a.title));
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -235,12 +249,14 @@ export function TranscriptionsBrowser({
currentView,
years,
places,
books,
lectureCount,
conversationCount,
}: {
currentView: string;
years: YearGroup[];
places: PlaceGroup[];
books: BookGroup[];
lectureCount: number;
conversationCount: number;
}) {
@@ -248,6 +264,7 @@ export function TranscriptionsBrowser({
const [page, setPage] = useState(1);
const [sortYear, setSortYear] = useState<SortMode>("asc");
const [sortPlace, setSortPlace] = useState<SortMode>("count");
const [sortBook, setSortBook] = useState<SortMode>("count");
const handleQueryChange = (q: string) => {
setQuery(q);
@@ -256,11 +273,17 @@ export function TranscriptionsBrowser({
const handleSortChange = (mode: SortMode) => {
if (currentView === "year") setSortYear(mode);
else if (currentView === "book") setSortBook(mode);
else setSortPlace(mode);
setPage(1);
};
const currentSort = currentView === "year" ? sortYear : sortPlace;
const currentSort =
currentView === "year"
? sortYear
: currentView === "book"
? sortBook
: sortPlace;
// --- Filtered + sorted data ---
@@ -282,13 +305,30 @@ export function TranscriptionsBrowser({
return sortPlaces(data, sortPlace);
}, [places, query, sortPlace]);
const filteredBooks = useMemo(() => {
let data = books;
if (query.trim()) {
const q = normalize(query.trim());
data = data.filter((bg) => normalize(bg.title).includes(q));
}
return sortBooks(data, sortBook);
}, [books, query, sortBook]);
// --- Paginated data ---
const activeData =
currentView === "year" ? filteredYears : filteredPlaces;
currentView === "year"
? filteredYears
: currentView === "book"
? filteredBooks
: filteredPlaces;
const pageSize =
currentView === "year" ? PAGE_SIZE_YEARS : PAGE_SIZE_PLACES;
currentView === "year"
? PAGE_SIZE_YEARS
: currentView === "book"
? PAGE_SIZE_BOOKS
: PAGE_SIZE_PLACES;
const totalPages = Math.max(1, Math.ceil(activeData.length / pageSize));
const safePage = Math.min(page, totalPages);
@@ -296,11 +336,26 @@ export function TranscriptionsBrowser({
const endIdx = startIdx + pageSize;
const placeholder =
currentView === "year" ? "Search years..." : "Search places...";
currentView === "year"
? "Search years..."
: currentView === "book"
? "Search books..."
: "Search places...";
const resultCount = activeData.length;
const totalCount =
currentView === "year" ? years.length : places.length;
currentView === "year"
? years.length
: currentView === "book"
? books.length
: places.length;
const noun =
currentView === "year"
? "years"
: currentView === "book"
? "books"
: "places";
// --- Type view (no search/sort/pagination needed) ---
@@ -368,7 +423,7 @@ export function TranscriptionsBrowser({
<p className="text-sm text-muted-foreground">
{resultCount === 0
? "No results found"
: `${resultCount} of ${totalCount} ${currentView === "year" ? "years" : "places"}`}
: `${resultCount} of ${totalCount} ${noun}`}
</p>
)}
@@ -415,6 +470,30 @@ export function TranscriptionsBrowser({
</div>
)}
{/* Book view */}
{currentView === "book" && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{(filteredBooks.slice(startIdx, endIdx) as BookGroup[]).map((bg) => (
<Link
key={bg.code}
href={`/transcriptions/book/${bg.code}`}
>
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
<CardContent className="py-6 px-4 text-center">
<p className="font-semibold text-foreground leading-tight">
{bg.title}
</p>
<p className="text-xs text-muted-foreground mt-1">
{bg.count}{" "}
{bg.count === 1 ? "transcription" : "transcriptions"}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
)}
{/* Pagination */}
<Pagination
page={safePage}
@@ -2,11 +2,12 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Calendar, MapPin, Mic } from "lucide-react";
import { BookOpen, Calendar, MapPin, Mic } from "lucide-react";
const views = [
{ key: "year", label: "Year", icon: Calendar },
{ key: "place", label: "Place", icon: MapPin },
{ key: "book", label: "Books", icon: BookOpen },
{ key: "type", label: "Type", icon: Mic },
];
+172
View File
@@ -0,0 +1,172 @@
/**
* Book + chapter derivation for transcriptions.
*
* Transcriptions carry a free-text scripture `reference` (e.g. "SB 1.2.3",
* "BG Introduction", "CC Madhya-līlā 20.101") rather than structured
* book/chapter columns. This module is the single source of truth for turning
* those strings into a book identity and a chapter grouping, reused by the
* books grid, the listing page, and the book browser.
*
* The book registry mirrors `TranscriptionBooks` in the Go BFF
* (internal/repository/transcription_repo.go).
*/
export type BookCode = "SB" | "BG" | "CC" | "BS" | "NOD" | "ISO";
export const BOOKS: Record<BookCode, { title: string; order: number }> = {
SB: { title: "Śrīmad-Bhāgavatam", order: 1 },
BG: { title: "Bhagavad-gītā", order: 2 },
CC: { title: "Caitanya-caritāmṛta", order: 3 },
BS: { title: "Brahma-saṁhitā", order: 4 },
NOD: { title: "The Nectar of Devotion", order: 5 },
ISO: { title: "Śrī Īśopaniṣad", order: 6 },
};
/** Display title for a (possibly unknown) book code; "" if unknown. */
export function bookTitle(code: string): string {
const b = BOOKS[code.toUpperCase() as BookCode];
return b ? b.title : "";
}
/** A section is a canto (SB) or līlā (CC); null for books without that level. */
export type ChapterSection = {
key: string;
label: string;
order: number;
};
/** A chapter grouping for the accordion list. */
export type ChapterGroup = {
key: string;
label: string;
sort: [number, number];
/** Canto/līlā this chapter belongs to (SB, CC); null for flat books. */
section: ChapterSection | null;
};
// Caitanya-caritāmṛta līlā ordering (Ādi → Madhya → Antya).
const LILA_ORDER: Record<string, { label: string; order: number }> = {
ADI: { label: "Ādi-līlā", order: 1 },
MADHYA: { label: "Madhya-līlā", order: 2 },
ANTYA: { label: "Antya-līlā", order: 3 },
};
const OTHER: ChapterGroup = {
key: "other",
label: "Other",
sort: [9999, 0],
section: null,
};
/**
* Parse a transcription reference into its chapter group, given the book code.
* Tolerates the corpus's noise (en-dash vs hyphen, "Madhya" vs "Madhya-līlā",
* a stray "SB 7", etc.). Unparseable / missing references fall into "Other".
*/
export function parseChapter(code: string, reference: string | null): ChapterGroup {
const ref = (reference ?? "").trim();
if (!ref) return OTHER;
switch (code.toUpperCase() as BookCode) {
case "BG": {
if (/^BG\s+Introduction/i.test(ref)) {
return {
key: "bg-intro",
label: "Introduction",
sort: [0, 0],
section: null,
};
}
const m = ref.match(/^BG\s+(\d+)/);
if (m) {
const n = Number(m[1]);
return {
key: `bg-${n}`,
label: `Chapter ${n}`,
sort: [n, 0],
section: null,
};
}
return OTHER;
}
case "SB": {
const m = ref.match(/^SB\s+(\d+)\.(\d+)/);
if (m) {
const c = Number(m[1]);
const ch = Number(m[2]);
return {
key: `sb-${c}-${ch}`,
label: `Chapter ${ch}`,
sort: [c, ch],
section: { key: `sb-${c}`, label: `Canto ${c}`, order: c },
};
}
const cantoOnly = ref.match(/^SB\s+(\d+)\b/);
if (cantoOnly) {
const c = Number(cantoOnly[1]);
return {
key: `sb-${c}-0`,
label: "Other",
sort: [c, 0],
section: { key: `sb-${c}`, label: `Canto ${c}`, order: c },
};
}
return OTHER;
}
case "CC": {
const m = ref.match(/^CC\s+(Ādi|Adi|Madhya|Antya)(?:-līlā)?\s+(\d+)/i);
if (m) {
const lila = LILA_ORDER[m[1].toUpperCase().replace("Ā", "A")];
const ch = Number(m[2]);
if (lila) {
return {
key: `cc-${lila.order}-${ch}`,
label: `Chapter ${ch}`,
sort: [lila.order, ch],
section: {
key: `cc-${lila.order}`,
label: lila.label,
order: lila.order,
},
};
}
}
return OTHER;
}
case "BS": {
const m = ref.match(/^BS\s+(\d+)/);
if (m) {
const n = Number(m[1]);
return {
key: `bs-${n}`,
label: `Chapter ${n}`,
sort: [n, 0],
section: null,
};
}
return OTHER;
}
case "ISO": {
const m = ref.match(/^ISO\s+Mantra\s+(\d+)/i);
if (m) {
const n = Number(m[1]);
return {
key: `iso-${n}`,
label: `Mantra ${n}`,
sort: [n, 0],
section: null,
};
}
return { key: "iso", label: "Śrī Īśopaniṣad", sort: [0, 0], section: null };
}
case "NOD":
return {
key: "nod",
label: "The Nectar of Devotion",
sort: [0, 0],
section: null,
};
default:
return OTHER;
}
}
+26
View File
@@ -59,6 +59,12 @@ export type PlaceGroup = {
count: number;
};
export type BookGroup = {
code: string;
title: string;
count: number;
};
// ---------------------------------------------------------------------------
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
@@ -185,6 +191,16 @@ export async function getTranscriptionPlaces(): Promise<PlaceGroup[]> {
.sort((a, b) => b.count - a.count);
}
export async function getTranscriptionBooks(): Promise<BookGroup[]> {
const raw = await bffList<{ _id: string; title: string; count: number }>(
"/transcriptions/books",
{ tags: ["transcriptions"] },
);
return raw
.map((item) => ({ code: item._id, title: item.title, count: item.count }))
.sort((a, b) => b.count - a.count);
}
export async function getTotalTranscriptionCount(): Promise<number> {
const res = await bff<{ count: number }>("/transcriptions", {
tags: ["transcriptions"],
@@ -248,6 +264,16 @@ export async function getTranscriptionsByType(
return raw.map(mapSummary);
}
export async function getTranscriptionsByBook(
code: string,
): Promise<TranscriptionSummary[]> {
const raw = await bffList<BffSummary>(
`/transcriptions/book/${encodeURIComponent(code)}`,
{ tags: ["transcriptions"] },
);
return raw.map(mapSummary);
}
// ---------------------------------------------------------------------------
// Single transcription
// ---------------------------------------------------------------------------