Files
spsays-training/app/books/chapter-viewer.tsx
T
2026-04-14 19:12:11 +05:30

477 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<Verse[]>(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<HTMLDivElement>(null);
// ------ Sidebar state ------
const [activeVerse, setActiveVerse] = useState<number | null>(null);
const [loadedVerses, setLoadedVerses] = useState<Set<number>>(() => {
const set = new Set<number>();
for (const v of initialVerses) set.add(v.verse);
return set;
});
const [pendingVerse, setPendingVerse] = useState<number | null>(null);
// Reset when route changes (different chapter)
useEffect(() => {
setVerses(initialVerses);
setHasMorePages(initialHasMore);
setHighestPageFetched(1);
setTotalPagesKnown(initialTotalPages);
const set = new Set<number>();
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<number>();
for (const v of verses) set.add(v.verse);
setLoadedVerses(set);
}, [verses]);
// =====================================================================
// Fetching
// =====================================================================
const fetchPage = useCallback(
async (page: number): Promise<PaginatedResponse | null> => {
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 ---- */}
<aside className="hidden lg:block w-48 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">
Verses
</h2>
<nav className="grid grid-cols-3 gap-0.5">
{verseIndex.map((v) => {
const isLoaded = loadedVerses.has(v.verse);
const isActive = activeVerse === v.verse;
const isPending = pendingVerse === v.verse;
return (
<a
key={v.verse}
href={`#verse-${v.verse}`}
onClick={(e) => handleIndexClick(e, v.verse)}
className={`px-2 py-1 rounded-md text-sm text-center transition-colors ${
isPending
? "bg-primary/20 text-primary animate-pulse font-medium"
: isActive
? "bg-primary/10 text-primary font-medium"
: isLoaded
? "text-muted-foreground hover:bg-muted hover:text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{v.verseEnd ? `${v.verse}–${v.verseEnd}` : v.verse}
</a>
);
})}
</nav>
</aside>
{/* ---- Main: Verse Cards ---- */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-12">
{verses.map((verse) => (
<Card
key={`${verse.book}-${verse.division}-${verse.chapter}-${verse.verse}`}
id={`verse-${verse.verse}`}
className="scroll-mt-24"
>
<CardHeader className="border-b border-border bg-muted/50 py-3">
<Badge variant="secondary">{verse.reference}</Badge>
</CardHeader>
<CardContent className="space-y-5 pt-5">
{/* Bengali (CC only) */}
{showBengali && verse.bengali && (
<div className="text-center">
<p className="text-xl leading-relaxed whitespace-pre-line font-serif text-green-800 dark:text-green-400">
{verse.bengali}
</p>
</div>
)}
{/* Devanagari */}
<div className="text-center">
<p className="text-xl leading-relaxed text-red-800 dark:text-red-400 whitespace-pre-line font-serif">
{verse.devanagari}
</p>
</div>
{/* Transliteration */}
<div className="text-center">
<p className="text-base italic text-muted-foreground whitespace-pre-line">
{verse.transliteration}
</p>
</div>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
Synonyms
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
<span className="italic text-foreground">{s.word}</span>
—{s.meanings.join(", ")}
{i < verse.synonyms.length - 1 ? "; " : "."}
</span>
))}
</p>
</div>
)}
{/* Translation */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
Translation
</h3>
<p
className="text-base text-foreground leading-relaxed font-medium"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</div>
{/* Purport */}
{verse.purport.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
Purport
</h3>
<div className="text-sm text-muted-foreground leading-relaxed space-y-3">
{verse.purport.map((p, i) => (
<p key={i} dangerouslySetInnerHTML={{ __html: p }} />
))}
</div>
</div>
)}
</CardContent>
</Card>
))}
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
{/* Loading indicator */}
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground gap-2">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm">Loading more verses…</span>
</div>
)}
</main>
</>
);
}