"use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Loader2 } from "lucide-react"; // --------------------------------------------------------------------------- // 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 PaginatedResponse = { verses: Verse[]; total: number; page: number; limit: number; totalPages: number; hasMore: boolean; }; type VerseIndexEntry = { verse: number; verseEnd?: number; reference: string; }; // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- type ChapterViewerProps = { initialVerses: Verse[]; hasMore: boolean; totalPages: number; book: string; division: string | null; chapter: number; limit: number; showBengali?: boolean; verseIndex: VerseIndexEntry[]; }; // --------------------------------------------------------------------------- // ChapterViewer // --------------------------------------------------------------------------- /** * ChapterViewer — a single "use client" component that owns both the sidebar * verse index and the main verse list with infinite scroll + jump-to-verse. * * This avoids the server→client boundary issue of passing functions as props * (render props / callbacks can't cross that boundary). * * The server component only passes serialisable data: * - initialVerses (page 1 of full verse data, SSR'd) * - verseIndex (lightweight list of ALL verse numbers/references) * - pagination metadata (hasMore, totalPages, book, division, chapter, limit) */ export function ChapterViewer({ initialVerses, hasMore: initialHasMore, totalPages: initialTotalPages, book, division, chapter, limit, showBengali = false, verseIndex, }: ChapterViewerProps) { // ------ Verse data state ------ const [verses, setVerses] = useState(initialVerses); const [loading, setLoading] = useState(false); const [hasMorePages, setHasMorePages] = useState(initialHasMore); const [highestPageFetched, setHighestPageFetched] = useState(1); const [totalPagesKnown, setTotalPagesKnown] = useState(initialTotalPages); const fetchingRef = useRef(false); const sentinelRef = useRef(null); // ------ Sidebar state ------ const [activeVerse, setActiveVerse] = useState(null); const [loadedVerses, setLoadedVerses] = useState>(() => { const set = new Set(); for (const v of initialVerses) set.add(v.verse); return set; }); const [pendingVerse, setPendingVerse] = useState(null); // Reset when route changes (different chapter) useEffect(() => { setVerses(initialVerses); setHasMorePages(initialHasMore); setHighestPageFetched(1); setTotalPagesKnown(initialTotalPages); const set = new Set(); for (const v of initialVerses) set.add(v.verse); setLoadedVerses(set); setPendingVerse(null); setActiveVerse(null); }, [initialVerses, initialHasMore, initialTotalPages]); // ---- Update loadedVerses whenever verses array changes ---- useEffect(() => { const set = new Set(); for (const v of verses) set.add(v.verse); setLoadedVerses(set); }, [verses]); // ===================================================================== // Fetching // ===================================================================== const fetchPage = useCallback( async (page: number): Promise => { const params = new URLSearchParams({ chapter: String(chapter), page: String(page), limit: String(limit), }); 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}`); return await res.json(); } catch (err) { console.error(`Failed to fetch page ${page}:`, err); return null; } }, [book, division, chapter, limit], ); // Fetch the next single page (for infinite scroll) const fetchNextPage = useCallback(async () => { if (fetchingRef.current || !hasMorePages) return; fetchingRef.current = true; setLoading(true); const nextPage = highestPageFetched + 1; const data = await fetchPage(nextPage); if (data) { setVerses((prev) => [...prev, ...data.verses]); setHasMorePages(data.hasMore); setHighestPageFetched(nextPage); setTotalPagesKnown(data.totalPages); } setLoading(false); fetchingRef.current = false; }, [hasMorePages, highestPageFetched, fetchPage]); // Fetch all pages up to the one containing targetVerse const requestVerse = useCallback( async (targetVerse: number) => { // Already loaded? if (verses.some((v) => v.verse === targetVerse)) { // Just scroll to it const el = document.getElementById(`verse-${targetVerse}`); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); return; } if (fetchingRef.current) return; fetchingRef.current = true; setLoading(true); setPendingVerse(targetVerse); let currentHighest = highestPageFetched; const allNewVerses: Verse[] = []; let found = false; let moreRemaining = hasMorePages; while (!found && currentHighest < totalPagesKnown) { const nextPage = currentHighest + 1; const data = await fetchPage(nextPage); if (!data || data.verses.length === 0) break; allNewVerses.push(...data.verses); currentHighest = nextPage; moreRemaining = data.hasMore; setTotalPagesKnown(data.totalPages); if (data.verses.some((v) => v.verse === targetVerse)) { found = true; } } if (allNewVerses.length > 0) { setVerses((prev) => [...prev, ...allNewVerses]); setHighestPageFetched(currentHighest); setHasMorePages(moreRemaining); } setLoading(false); fetchingRef.current = false; // pendingVerse will trigger scroll via the effect below }, [verses, highestPageFetched, hasMorePages, totalPagesKnown, fetchPage], ); // ===================================================================== // Scroll to pending verse once it appears in the DOM // ===================================================================== useEffect(() => { if (pendingVerse === null) return; if (!loadedVerses.has(pendingVerse)) return; requestAnimationFrame(() => { setTimeout(() => { const el = document.getElementById(`verse-${pendingVerse}`); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } setPendingVerse(null); }, 50); }); }, [loadedVerses, pendingVerse]); // ===================================================================== // Infinite scroll observer (bottom sentinel) // ===================================================================== useEffect(() => { const sentinel = sentinelRef.current; if (!sentinel) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasMorePages && !fetchingRef.current) { fetchNextPage(); } }, { rootMargin: "600px" }, ); observer.observe(sentinel); return () => observer.disconnect(); }, [fetchNextPage, hasMorePages]); // ===================================================================== // Active verse scroll tracking (IntersectionObserver + MutationObserver) // ===================================================================== useEffect(() => { const intersectionObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { const num = parseInt(entry.target.id.replace("verse-", "")); if (!isNaN(num)) setActiveVerse(num); } } }, { rootMargin: "-20% 0px -60% 0px" }, ); const observeAll = () => { const els = document.querySelectorAll("[id^='verse-']"); els.forEach((el) => intersectionObserver.observe(el)); }; observeAll(); // Watch for new verse cards from infinite scroll const mutationObserver = new MutationObserver((mutations) => { let hasNew = false; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if ( node instanceof HTMLElement && (node.id?.startsWith("verse-") || node.querySelector?.("[id^='verse-']")) ) { hasNew = true; break; } } if (hasNew) break; } if (hasNew) observeAll(); }); const main = document.querySelector("main"); if (main) { mutationObserver.observe(main, { childList: true, subtree: true }); } return () => { intersectionObserver.disconnect(); mutationObserver.disconnect(); }; }, []); // ===================================================================== // Sidebar click handler // ===================================================================== const handleIndexClick = useCallback( (e: React.MouseEvent, verseNum: number) => { e.preventDefault(); const el = document.getElementById(`verse-${verseNum}`); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); return; } // Not loaded — request it setPendingVerse(verseNum); requestVerse(verseNum); }, [requestVerse], ); // ===================================================================== // Render // ===================================================================== return ( <> {/* ---- Sidebar: Verse Index ---- */} {/* ---- Main: Verse Cards ---- */}
{verses.map((verse) => ( {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) => (

))}

)}
))} {/* Sentinel for infinite scroll */}
{/* Loading indicator */} {loading && (
Loading more verses…
)}
); }