From f5abe33c09524a37b267cc9be7ec8a673fca66a1 Mon Sep 17 00:00:00 2001 From: gosangam Date: Wed, 17 Jun 2026 12:40:40 +0530 Subject: [PATCH] feat: add book transcripts categorisation --- .../book/[book]/book-browser.tsx | 590 ++++++++++++++++++ app/(app)/transcriptions/book/[book]/page.tsx | 49 ++ app/(app)/transcriptions/page.tsx | 5 +- .../transcriptions/transcriptions-browser.tsx | 95 ++- .../transcriptions/transcriptions-nav.tsx | 3 +- lib/book-reference.ts | 172 +++++ lib/transcriptions.ts | 26 + 7 files changed, 930 insertions(+), 10 deletions(-) create mode 100644 app/(app)/transcriptions/book/[book]/book-browser.tsx create mode 100644 app/(app)/transcriptions/book/[book]/page.tsx create mode 100644 lib/book-reference.ts diff --git a/app/(app)/transcriptions/book/[book]/book-browser.tsx b/app/(app)/transcriptions/book/[book]/book-browser.tsx new file mode 100644 index 00000000..70ad0909 --- /dev/null +++ b/app/(app)/transcriptions/book/[book]/book-browser.tsx @@ -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(); + + 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 ( + + ); +} + +// --------------------------------------------------------------------------- +// Mobile drawer +// --------------------------------------------------------------------------- + +function YearMobileNav({ + groups, + activeYear, + onSelect, + title, +}: { + groups: YearGroup[]; + activeYear: number; + onSelect: (year: number) => void; + title: string; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + Years + + + + {title} + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ + + {prev ? ( + + ) : ( +
+ )} +
+ + {next ? ( + + ) : ( +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Chapter section — collapsible group of transcriptions for a single chapter +// --------------------------------------------------------------------------- + +function ChapterSection({ + group, + defaultOpen, +}: { + group: ChapterGroup; + defaultOpen: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + + {open && ( +
    + {group.transcriptions.map((t) => ( +
  • + +
    +
    +

    + {t.name} +

    + + {t.type === "lecture" ? "Lecture" : "Conversation"} + +
    +
    + {t.date && ( + + + {t.date} + + )} + {t.place && ( + + + {t.place} + + )} + {t.reference && ( + {t.reference} + )} + {t.durationMinutes && t.durationMinutes > 0 && ( + + + {t.durationMinutes} min + + )} +
    +
    + {t.audioUrl && ( + + )} + +
  • + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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( + 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(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 */} +
+
+ + ← Transcriptions by Book + +

+ {title}: + {activeYearLabel} +

+

+ {activeGroup?.count ?? 0}{" "} + {(activeGroup?.count ?? 0) === 1 + ? "transcription" + : "transcriptions"}{" "} + in {activeYearLabel} · {totalCount} total +

+
+
+ + {/* Sidebar + Main */} +
+ {/* Left sidebar — years */} + + + {/* Main content */} +
+ {/* Top navigation */} + + + {/* Content — sections (canto/līlā) → chapter accordions */} + {sections.length === 0 ? ( +
+

+ No transcriptions for this year +

+
+ ) : ( +
+ {sections.map((section) => ( +
+ {section.label && ( +

+ {section.label} +

+ )} + + + {section.chapters.map((cg, i) => ( + + ))} + + +
+ ))} +
+ )} + + {/* Bottom navigation */} + +
+
+ + ); +} diff --git a/app/(app)/transcriptions/book/[book]/page.tsx b/app/(app)/transcriptions/book/[book]/page.tsx new file mode 100644 index 00000000..f8db792c --- /dev/null +++ b/app/(app)/transcriptions/book/[book]/page.tsx @@ -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 { + 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 ( +
+ +
+ ); +} diff --git a/app/(app)/transcriptions/page.tsx b/app/(app)/transcriptions/page.tsx index ad164dbb..f8e0ff03 100644 --- a/app/(app)/transcriptions/page.tsx +++ b/app/(app)/transcriptions/page.tsx @@ -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} /> diff --git a/app/(app)/transcriptions/transcriptions-browser.tsx b/app/(app)/transcriptions/transcriptions-browser.tsx index dcf03fd7..6e82a629 100644 --- a/app/(app)/transcriptions/transcriptions-browser.tsx +++ b/app/(app)/transcriptions/transcriptions-browser.tsx @@ -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("asc"); const [sortPlace, setSortPlace] = useState("count"); + const [sortBook, setSortBook] = useState("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({

{resultCount === 0 ? "No results found" - : `${resultCount} of ${totalCount} ${currentView === "year" ? "years" : "places"}`} + : `${resultCount} of ${totalCount} ${noun}`}

)} @@ -415,6 +470,30 @@ export function TranscriptionsBrowser({
)} + {/* Book view */} + {currentView === "book" && ( +
+ {(filteredBooks.slice(startIdx, endIdx) as BookGroup[]).map((bg) => ( + + + +

+ {bg.title} +

+

+ {bg.count}{" "} + {bg.count === 1 ? "transcription" : "transcriptions"} +

+
+
+ + ))} +
+ )} + {/* Pagination */} = { + 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 = { + 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; + } +} diff --git a/lib/transcriptions.ts b/lib/transcriptions.ts index 2f670bdb..b1cd8e2b 100644 --- a/lib/transcriptions.ts +++ b/lib/transcriptions.ts @@ -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 { .sort((a, b) => b.count - a.count); } +export async function getTranscriptionBooks(): Promise { + 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 { 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 { + const raw = await bffList( + `/transcriptions/book/${encodeURIComponent(code)}`, + { tags: ["transcriptions"] }, + ); + return raw.map(mapSummary); +} + // --------------------------------------------------------------------------- // Single transcription // ---------------------------------------------------------------------------