"use client"; import { useRouter } from "next/navigation"; import { useCallback, useMemo, useEffect, useState } from "react"; import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; import { MobileNav, type MobileNavGroup } from "@/components/layout/mobile-nav"; import { JsonLd } from "@/components/seo/json-ld"; import { articleJsonLd } from "@/lib/jsonld"; import { BOOK_NAMES } from "@/lib/constants"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type Synonym = { word: string; meanings: string[]; }; type Verse = { id: string; book: string; division: string | null; chapter: number; verse: number; verseEnd: number | null; reference: string; bookTitle: string; devanagari: string; bengali: string | null; transliteration: string; synonyms: Synonym[]; translation: string; purport: string[]; }; type VerseIndexEntry = { verse: number; verseEnd?: number; reference: string; }; // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- type ChapterViewerProps = { /** The first verse (SSR'd), shown before any client-side fetch */ initialVerse: Verse; /** The verse number of the initial verse */ initialVerseNum: number; /** Lightweight index of ALL verses in the chapter (for the sidebar) */ verseIndex: VerseIndexEntry[]; /** Book key */ book: string; /** Division: null for BG, canto string for SB, lila key for CC */ division: string | null; /** Chapter number */ chapter: number; /** Whether to show the Bengali script field (CC only) */ showBengali?: boolean; /** Base path for verse URLs, e.g. "/books/bg/1" — verse number is appended */ basePath: string; /** Mobile nav groups for the drawer */ mobileNavGroups?: MobileNavGroup[]; /** Book title for the mobile nav drawer header */ mobileNavBookTitle?: string; }; // --------------------------------------------------------------------------- // Navigation buttons (reused at top and bottom) // --------------------------------------------------------------------------- function VerseNav({ currentIdx, total, onPrev, onNext, verseIndex, mobileNavGroups, currentVerseSlug, mobileNavBookTitle, }: { currentIdx: number; total: number; onPrev: () => void; onNext: () => void; verseIndex: VerseIndexEntry[]; mobileNavGroups?: MobileNavGroup[]; currentVerseSlug?: string; mobileNavBookTitle?: string; }) { const hasPrev = currentIdx > 0; const hasNext = currentIdx < total - 1; const current = verseIndex[currentIdx]; const label = current ? current.verseEnd ? `${current.verse}–${current.verseEnd}` : String(current.verse) : ""; return (
{mobileNavGroups && mobileNavGroups.length > 0 && ( )}
Verse {label} of {total}
); } // --------------------------------------------------------------------------- // Verse card // --------------------------------------------------------------------------- function VerseCard({ verse, showBengali, }: { verse: Verse; showBengali: boolean; }) { return ( {verse.reference} {/* Bengali (CC only) */} {showBengali && verse.bengali && (

{verse.bengali}

)} {/* Devanagari */}

{verse.devanagari}

{/* Transliteration */}

{verse.transliteration}

{/* Synonyms */} {verse.synonyms.length > 0 && (

Synonyms

{verse.synonyms.map((s, i) => ( {s.word}— {s.meanings.join(", ")} {i < verse.synonyms.length - 1 ? "; " : "."} ))}

)} {/* Translation */}

Translation

{/* Purport */} {verse.purport.length > 0 && (

Purport

{verse.purport.map((p, i) => // Cited ślokas come wrapped in
— a // block element that can't live inside a

, so render those // in a

(styled centered + accent via globals.css); plain // prose paragraphs stay as

. /^\s*

) : (

), )}

)} ); } // --------------------------------------------------------------------------- // ChapterViewer // --------------------------------------------------------------------------- export function ChapterViewer({ initialVerse, initialVerseNum, verseIndex, book, division, chapter, showBengali = false, basePath, mobileNavGroups, mobileNavBookTitle, }: ChapterViewerProps) { const router = useRouter(); // Verse cache: verseNum → Verse data const [cache, setCache] = useState>(() => { const map = new Map(); map.set(initialVerseNum, initialVerse); return map; }); const [loading, setLoading] = useState(false); // Current verse number — driven by the initialVerseNum from the server // (which reads it from the [verse] path param) const currentVerseNum = initialVerseNum; // Current index in the verseIndex array const currentIdx = useMemo( () => verseIndex.findIndex((v) => v.verse === currentVerseNum), [verseIndex, currentVerseNum], ); // The verse data to display const currentVerse = cache.get(currentVerseNum) ?? null; // Reset cache when route changes to a different chapter useEffect(() => { const map = new Map(); map.set(initialVerseNum, initialVerse); setCache(map); }, [initialVerse, initialVerseNum]); // Fetch a verse if it's not in the cache const fetchVerse = useCallback( async (verseNum: number) => { if (cache.has(verseNum)) return cache.get(verseNum)!; setLoading(true); const params = new URLSearchParams({ chapter: String(chapter), verse: String(verseNum), }); if (division !== null) { params.set("division", division); } try { const res = await fetch(`/api/books/${book}/verses?${params}`); if (!res.ok) throw new Error(`API error: ${res.status}`); const data = await res.json(); // The API returns the verse in a paginated wrapper or an array const verse: Verse | undefined = Array.isArray(data.verses) ? data.verses[0] : Array.isArray(data) ? data[0] : undefined; if (verse) { setCache((prev) => { const next = new Map(prev); next.set(verseNum, verse); return next; }); return verse; } } catch (err) { console.error("Failed to fetch verse:", err); } finally { setLoading(false); } return null; }, [cache, book, division, chapter], ); // Navigate to a verse — update the URL path and fetch if needed const navigateTo = useCallback( async (verseNum: number) => { // Prefetch the verse data before navigating if (!cache.has(verseNum)) { await fetchVerse(verseNum); } // Navigate to the new path-based URL router.push(`${basePath}/${verseNum}`, { scroll: false }); // Scroll to top of the verse window.scrollTo({ top: 0, behavior: "smooth" }); }, [basePath, router, cache, fetchVerse], ); // Fetch current verse on mount/URL change if not cached useEffect(() => { if (!cache.has(currentVerseNum)) { fetchVerse(currentVerseNum); } }, [currentVerseNum, cache, fetchVerse]); // Prefetch adjacent verses useEffect(() => { if (currentIdx > 0) { const prevNum = verseIndex[currentIdx - 1].verse; if (!cache.has(prevNum)) fetchVerse(prevNum); } if (currentIdx < verseIndex.length - 1) { const nextNum = verseIndex[currentIdx + 1].verse; if (!cache.has(nextNum)) fetchVerse(nextNum); } }, [currentIdx, verseIndex, cache, fetchVerse]); // Keyboard navigation useEffect(() => { const handleKey = (e: KeyboardEvent) => { if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement ) return; if (e.key === "ArrowLeft" && currentIdx > 0) { e.preventDefault(); navigateTo(verseIndex[currentIdx - 1].verse); } else if (e.key === "ArrowRight" && currentIdx < verseIndex.length - 1) { e.preventDefault(); navigateTo(verseIndex[currentIdx + 1].verse); } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, [currentIdx, verseIndex, navigateTo]); const goPrev = useCallback(() => { if (currentIdx > 0) navigateTo(verseIndex[currentIdx - 1].verse); }, [currentIdx, verseIndex, navigateTo]); const goNext = useCallback(() => { if (currentIdx < verseIndex.length - 1) navigateTo(verseIndex[currentIdx + 1].verse); }, [currentIdx, verseIndex, navigateTo]); return ( <> {/* Structured data for the currently-shown verse (SSR'd for the initial verse, updates on client navigation). */} {currentVerse ? ( ) : null} {/* ---- Sidebar: Verse Index ---- */} {/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for the site footer so the sidebar doesn't slide behind it when the page is scrolled to the bottom. (89 sticky-top + 76 bottom gap = 165, hence calc(100vh - 165px).) */} {/* ---- Main: Single Verse ---- */}
{/* Top navigation */} {/* Verse content — id targeted by each page header's FindInPage. */}
{loading && !currentVerse ? (
Loading verse…
) : currentVerse ? ( ) : (
Verse not found.
)}
{/* Bottom navigation */}
); }