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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { ogForTranscriptions } from "@/lib/og";
|
|||||||
import {
|
import {
|
||||||
getTranscriptionYears,
|
getTranscriptionYears,
|
||||||
getTranscriptionPlaces,
|
getTranscriptionPlaces,
|
||||||
|
getTranscriptionBooks,
|
||||||
getTotalTranscriptionCount,
|
getTotalTranscriptionCount,
|
||||||
getTranscriptionIndex,
|
getTranscriptionIndex,
|
||||||
} from "@/lib/transcriptions";
|
} from "@/lib/transcriptions";
|
||||||
@@ -24,9 +25,10 @@ export default async function TranscriptionsPage({
|
|||||||
}) {
|
}) {
|
||||||
const { view = "year" } = await searchParams;
|
const { view = "year" } = await searchParams;
|
||||||
|
|
||||||
const [years, places, totalCount, index] = await Promise.all([
|
const [years, places, books, totalCount, index] = await Promise.all([
|
||||||
getTranscriptionYears(),
|
getTranscriptionYears(),
|
||||||
getTranscriptionPlaces(),
|
getTranscriptionPlaces(),
|
||||||
|
getTranscriptionBooks(),
|
||||||
getTotalTranscriptionCount(),
|
getTotalTranscriptionCount(),
|
||||||
getTranscriptionIndex(),
|
getTranscriptionIndex(),
|
||||||
]);
|
]);
|
||||||
@@ -56,6 +58,7 @@ export default async function TranscriptionsPage({
|
|||||||
currentView={view}
|
currentView={view}
|
||||||
years={years}
|
years={years}
|
||||||
places={places}
|
places={places}
|
||||||
|
books={books}
|
||||||
lectureCount={index.typeDistribution.lecture}
|
lectureCount={index.typeDistribution.lecture}
|
||||||
conversationCount={index.typeDistribution.conversation}
|
conversationCount={index.typeDistribution.conversation}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
|
|
||||||
export type YearGroup = { year: number; count: number };
|
export type YearGroup = { year: number; count: number };
|
||||||
export type PlaceGroup = { name: string; count: number };
|
export type PlaceGroup = { name: string; count: number };
|
||||||
|
export type BookGroup = { code: string; title: string; count: number };
|
||||||
|
|
||||||
type SortMode = "count" | "asc" | "desc";
|
type SortMode = "count" | "asc" | "desc";
|
||||||
|
|
||||||
@@ -59,14 +60,14 @@ function normalize(text: string): string {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SORT_OPTIONS: { key: SortMode; label: string; icon: typeof Hash }[] = [
|
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: "asc", label: "A → Z", icon: ArrowDownAZ },
|
||||||
{ key: "desc", label: "Z → A", icon: ArrowUpAZ },
|
{ key: "desc", label: "Z → A", icon: ArrowUpAZ },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SORT_OPTIONS_YEAR: { key: SortMode; label: string; icon: typeof Hash }[] =
|
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: "asc", label: "Oldest first", icon: ArrowDownAZ },
|
||||||
{ key: "desc", label: "Newest first", icon: ArrowUpAZ },
|
{ key: "desc", label: "Newest first", icon: ArrowUpAZ },
|
||||||
];
|
];
|
||||||
@@ -150,6 +151,7 @@ function SortPopover({
|
|||||||
|
|
||||||
const PAGE_SIZE_PLACES = 60;
|
const PAGE_SIZE_PLACES = 60;
|
||||||
const PAGE_SIZE_YEARS = 100;
|
const PAGE_SIZE_YEARS = 100;
|
||||||
|
const PAGE_SIZE_BOOKS = 100;
|
||||||
|
|
||||||
function Pagination({
|
function Pagination({
|
||||||
page,
|
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
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -235,12 +249,14 @@ export function TranscriptionsBrowser({
|
|||||||
currentView,
|
currentView,
|
||||||
years,
|
years,
|
||||||
places,
|
places,
|
||||||
|
books,
|
||||||
lectureCount,
|
lectureCount,
|
||||||
conversationCount,
|
conversationCount,
|
||||||
}: {
|
}: {
|
||||||
currentView: string;
|
currentView: string;
|
||||||
years: YearGroup[];
|
years: YearGroup[];
|
||||||
places: PlaceGroup[];
|
places: PlaceGroup[];
|
||||||
|
books: BookGroup[];
|
||||||
lectureCount: number;
|
lectureCount: number;
|
||||||
conversationCount: number;
|
conversationCount: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -248,6 +264,7 @@ export function TranscriptionsBrowser({
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortYear, setSortYear] = useState<SortMode>("asc");
|
const [sortYear, setSortYear] = useState<SortMode>("asc");
|
||||||
const [sortPlace, setSortPlace] = useState<SortMode>("count");
|
const [sortPlace, setSortPlace] = useState<SortMode>("count");
|
||||||
|
const [sortBook, setSortBook] = useState<SortMode>("count");
|
||||||
|
|
||||||
const handleQueryChange = (q: string) => {
|
const handleQueryChange = (q: string) => {
|
||||||
setQuery(q);
|
setQuery(q);
|
||||||
@@ -256,11 +273,17 @@ export function TranscriptionsBrowser({
|
|||||||
|
|
||||||
const handleSortChange = (mode: SortMode) => {
|
const handleSortChange = (mode: SortMode) => {
|
||||||
if (currentView === "year") setSortYear(mode);
|
if (currentView === "year") setSortYear(mode);
|
||||||
|
else if (currentView === "book") setSortBook(mode);
|
||||||
else setSortPlace(mode);
|
else setSortPlace(mode);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentSort = currentView === "year" ? sortYear : sortPlace;
|
const currentSort =
|
||||||
|
currentView === "year"
|
||||||
|
? sortYear
|
||||||
|
: currentView === "book"
|
||||||
|
? sortBook
|
||||||
|
: sortPlace;
|
||||||
|
|
||||||
// --- Filtered + sorted data ---
|
// --- Filtered + sorted data ---
|
||||||
|
|
||||||
@@ -282,13 +305,30 @@ export function TranscriptionsBrowser({
|
|||||||
return sortPlaces(data, sortPlace);
|
return sortPlaces(data, sortPlace);
|
||||||
}, [places, query, 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 ---
|
// --- Paginated data ---
|
||||||
|
|
||||||
const activeData =
|
const activeData =
|
||||||
currentView === "year" ? filteredYears : filteredPlaces;
|
currentView === "year"
|
||||||
|
? filteredYears
|
||||||
|
: currentView === "book"
|
||||||
|
? filteredBooks
|
||||||
|
: filteredPlaces;
|
||||||
|
|
||||||
const pageSize =
|
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 totalPages = Math.max(1, Math.ceil(activeData.length / pageSize));
|
||||||
const safePage = Math.min(page, totalPages);
|
const safePage = Math.min(page, totalPages);
|
||||||
@@ -296,11 +336,26 @@ export function TranscriptionsBrowser({
|
|||||||
const endIdx = startIdx + pageSize;
|
const endIdx = startIdx + pageSize;
|
||||||
|
|
||||||
const placeholder =
|
const placeholder =
|
||||||
currentView === "year" ? "Search years..." : "Search places...";
|
currentView === "year"
|
||||||
|
? "Search years..."
|
||||||
|
: currentView === "book"
|
||||||
|
? "Search books..."
|
||||||
|
: "Search places...";
|
||||||
|
|
||||||
const resultCount = activeData.length;
|
const resultCount = activeData.length;
|
||||||
const totalCount =
|
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) ---
|
// --- Type view (no search/sort/pagination needed) ---
|
||||||
|
|
||||||
@@ -368,7 +423,7 @@ export function TranscriptionsBrowser({
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{resultCount === 0
|
{resultCount === 0
|
||||||
? "No results found"
|
? "No results found"
|
||||||
: `${resultCount} of ${totalCount} ${currentView === "year" ? "years" : "places"}`}
|
: `${resultCount} of ${totalCount} ${noun}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -415,6 +470,30 @@ export function TranscriptionsBrowser({
|
|||||||
</div>
|
</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 */}
|
||||||
<Pagination
|
<Pagination
|
||||||
page={safePage}
|
page={safePage}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Calendar, MapPin, Mic } from "lucide-react";
|
import { BookOpen, Calendar, MapPin, Mic } from "lucide-react";
|
||||||
|
|
||||||
const views = [
|
const views = [
|
||||||
{ key: "year", label: "Year", icon: Calendar },
|
{ key: "year", label: "Year", icon: Calendar },
|
||||||
{ key: "place", label: "Place", icon: MapPin },
|
{ key: "place", label: "Place", icon: MapPin },
|
||||||
|
{ key: "book", label: "Books", icon: BookOpen },
|
||||||
{ key: "type", label: "Type", icon: Mic },
|
{ key: "type", label: "Type", icon: Mic },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,12 @@ export type PlaceGroup = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BookGroup = {
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// BFF response types (snake_case from Go)
|
// BFF response types (snake_case from Go)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -185,6 +191,16 @@ export async function getTranscriptionPlaces(): Promise<PlaceGroup[]> {
|
|||||||
.sort((a, b) => b.count - a.count);
|
.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> {
|
export async function getTotalTranscriptionCount(): Promise<number> {
|
||||||
const res = await bff<{ count: number }>("/transcriptions", {
|
const res = await bff<{ count: number }>("/transcriptions", {
|
||||||
tags: ["transcriptions"],
|
tags: ["transcriptions"],
|
||||||
@@ -248,6 +264,16 @@ export async function getTranscriptionsByType(
|
|||||||
return raw.map(mapSummary);
|
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
|
// Single transcription
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user