feat: add pagination
This commit is contained in:
@@ -42,3 +42,4 @@ next-env.d.ts
|
|||||||
|
|
||||||
public/rtfs
|
public/rtfs
|
||||||
/lib/generated/prisma
|
/lib/generated/prisma
|
||||||
|
app/lib
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: Request,
|
req: Request,
|
||||||
{ params }: { params: Promise<{ book: string }> },
|
{ params }: { params: Promise<{ book: string }> },
|
||||||
@@ -11,6 +9,7 @@ export async function GET(
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const division = url.searchParams.get("division");
|
const division = url.searchParams.get("division");
|
||||||
const chapterStr = url.searchParams.get("chapter");
|
const chapterStr = url.searchParams.get("chapter");
|
||||||
|
const verseStr = url.searchParams.get("verse");
|
||||||
const indexOnly = url.searchParams.get("indexOnly") === "true";
|
const indexOnly = url.searchParams.get("indexOnly") === "true";
|
||||||
const pageStr = url.searchParams.get("page");
|
const pageStr = url.searchParams.get("page");
|
||||||
const limitStr = url.searchParams.get("limit");
|
const limitStr = url.searchParams.get("limit");
|
||||||
@@ -31,8 +30,21 @@ export async function GET(
|
|||||||
where.division = division;
|
where.division = division;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Single verse mode: ?verse=N ---
|
||||||
|
if (verseStr) {
|
||||||
|
const verseNum = parseInt(verseStr);
|
||||||
|
const verse = await prisma.verse.findFirst({
|
||||||
|
where: { ...where, verse: verseNum },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verse) {
|
||||||
|
return NextResponse.json({ error: "Verse not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(verse);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Index-only mode: return lightweight verse list for the sidebar ---
|
// --- Index-only mode: return lightweight verse list for the sidebar ---
|
||||||
// Returns all verses but only the fields needed for navigation
|
|
||||||
if (indexOnly) {
|
if (indexOnly) {
|
||||||
const verses = await prisma.verse.findMany({
|
const verses = await prisma.verse.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -49,10 +61,7 @@ export async function GET(
|
|||||||
|
|
||||||
// --- Paginated mode: return full verse data in pages ---
|
// --- Paginated mode: return full verse data in pages ---
|
||||||
const page = Math.max(1, parseInt(pageStr || "1"));
|
const page = Math.max(1, parseInt(pageStr || "1"));
|
||||||
const limit = Math.min(
|
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
|
||||||
100,
|
|
||||||
Math.max(1, parseInt(limitStr || String(DEFAULT_PAGE_SIZE))),
|
|
||||||
);
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const [verses, total] = await Promise.all([
|
const [verses, total] = await Promise.all([
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getChapterVerses, getChapterVerseIndex } from "@/lib/api";
|
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
||||||
|
|
||||||
type BgMeta = {
|
type BgMeta = {
|
||||||
@@ -14,11 +14,13 @@ type BgMeta = {
|
|||||||
|
|
||||||
export default async function ChapterPage({
|
export default async function ChapterPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ chapter: string }>;
|
params: Promise<{ chapter: string }>;
|
||||||
|
searchParams: Promise<{ verse?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { chapter } = await params;
|
const { chapter } = await params;
|
||||||
const PAGE_SIZE = 20;
|
const { verse: verseParam } = await searchParams;
|
||||||
|
|
||||||
const meta = await getBookMeta("bg");
|
const meta = await getBookMeta("bg");
|
||||||
if (!meta) notFound();
|
if (!meta) notFound();
|
||||||
@@ -31,12 +33,20 @@ export default async function ChapterPage({
|
|||||||
|
|
||||||
const title: string = chapterInfo.title;
|
const title: string = chapterInfo.title;
|
||||||
|
|
||||||
const [verseIndex, firstPage] = await Promise.all([
|
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
|
||||||
getChapterVerseIndex("bg", null, parseInt(chapter)),
|
|
||||||
getChapterVerses("bg", null, parseInt(chapter), 1, PAGE_SIZE),
|
|
||||||
]);
|
|
||||||
if (verseIndex.length === 0) notFound();
|
if (verseIndex.length === 0) notFound();
|
||||||
|
|
||||||
|
const requestedVerse = verseParam
|
||||||
|
? parseInt(verseParam)
|
||||||
|
: verseIndex[0].verse;
|
||||||
|
const verse = await getSingleVerse(
|
||||||
|
"bg",
|
||||||
|
null,
|
||||||
|
parseInt(chapter),
|
||||||
|
requestedVerse,
|
||||||
|
);
|
||||||
|
if (!verse) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<PageHeader sticky wide>
|
<PageHeader sticky wide>
|
||||||
@@ -53,19 +63,17 @@ export default async function ChapterPage({
|
|||||||
|
|
||||||
<div className="w-full md:max-w-5xl mx-auto flex">
|
<div className="w-full md:max-w-5xl mx-auto flex">
|
||||||
<ChapterViewer
|
<ChapterViewer
|
||||||
initialVerses={firstPage.verses}
|
initialVerse={verse}
|
||||||
hasMore={firstPage.hasMore}
|
initialVerseNum={requestedVerse}
|
||||||
totalPages={firstPage.totalPages}
|
|
||||||
book="bg"
|
|
||||||
division={null}
|
|
||||||
chapter={parseInt(chapter)}
|
|
||||||
limit={PAGE_SIZE}
|
|
||||||
showBengali={false}
|
|
||||||
verseIndex={verseIndex.map((v) => ({
|
verseIndex={verseIndex.map((v) => ({
|
||||||
verse: v.verse,
|
verse: v.verse,
|
||||||
verseEnd: v.verseEnd ?? undefined,
|
verseEnd: v.verseEnd ?? undefined,
|
||||||
reference: v.reference,
|
reference: v.reference,
|
||||||
}))}
|
}))}
|
||||||
|
book="bg"
|
||||||
|
division={null}
|
||||||
|
chapter={parseInt(chapter)}
|
||||||
|
showBengali={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getChapterVerses, getChapterVerseIndex } from "@/lib/api";
|
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
||||||
|
|
||||||
type ChapterInfo = {
|
type ChapterInfo = {
|
||||||
@@ -20,11 +20,13 @@ type LilaInfo = {
|
|||||||
|
|
||||||
export default async function CcChapterPage({
|
export default async function CcChapterPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ lila: string; chapter: string }>;
|
params: Promise<{ lila: string; chapter: string }>;
|
||||||
|
searchParams: Promise<{ verse?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { lila, chapter } = await params;
|
const { lila, chapter } = await params;
|
||||||
const PAGE_SIZE = 20;
|
const { verse: verseParam } = await searchParams;
|
||||||
|
|
||||||
const meta = await getBookMeta("cc");
|
const meta = await getBookMeta("cc");
|
||||||
if (!meta) notFound();
|
if (!meta) notFound();
|
||||||
@@ -38,12 +40,20 @@ export default async function CcChapterPage({
|
|||||||
);
|
);
|
||||||
if (!chapterInfo) notFound();
|
if (!chapterInfo) notFound();
|
||||||
|
|
||||||
const [verseIndex, firstPage] = await Promise.all([
|
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
|
||||||
getChapterVerseIndex("cc", lila, parseInt(chapter)),
|
|
||||||
getChapterVerses("cc", lila, parseInt(chapter), 1, PAGE_SIZE),
|
|
||||||
]);
|
|
||||||
if (verseIndex.length === 0) notFound();
|
if (verseIndex.length === 0) notFound();
|
||||||
|
|
||||||
|
const requestedVerse = verseParam
|
||||||
|
? parseInt(verseParam)
|
||||||
|
: verseIndex[0].verse;
|
||||||
|
const verse = await getSingleVerse(
|
||||||
|
"cc",
|
||||||
|
lila,
|
||||||
|
parseInt(chapter),
|
||||||
|
requestedVerse,
|
||||||
|
);
|
||||||
|
if (!verse) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<PageHeader sticky wide>
|
<PageHeader sticky wide>
|
||||||
@@ -60,19 +70,17 @@ export default async function CcChapterPage({
|
|||||||
|
|
||||||
<div className="w-full md:max-w-5xl mx-auto flex">
|
<div className="w-full md:max-w-5xl mx-auto flex">
|
||||||
<ChapterViewer
|
<ChapterViewer
|
||||||
initialVerses={firstPage.verses}
|
initialVerse={verse}
|
||||||
hasMore={firstPage.hasMore}
|
initialVerseNum={requestedVerse}
|
||||||
totalPages={firstPage.totalPages}
|
|
||||||
book="cc"
|
|
||||||
division={lila}
|
|
||||||
chapter={parseInt(chapter)}
|
|
||||||
limit={PAGE_SIZE}
|
|
||||||
showBengali={true}
|
|
||||||
verseIndex={verseIndex.map((v) => ({
|
verseIndex={verseIndex.map((v) => ({
|
||||||
verse: v.verse,
|
verse: v.verse,
|
||||||
verseEnd: v.verseEnd ?? undefined,
|
verseEnd: v.verseEnd ?? undefined,
|
||||||
reference: v.reference,
|
reference: v.reference,
|
||||||
}))}
|
}))}
|
||||||
|
book="cc"
|
||||||
|
division={lila}
|
||||||
|
chapter={parseInt(chapter)}
|
||||||
|
showBengali={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+325
-357
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useCallback, useMemo, useEffect, useState } from "react";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -31,15 +33,6 @@ type Verse = {
|
|||||||
purport: string[];
|
purport: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PaginatedResponse = {
|
|
||||||
verses: Verse[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalPages: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VerseIndexEntry = {
|
type VerseIndexEntry = {
|
||||||
verse: number;
|
verse: number;
|
||||||
verseEnd?: number;
|
verseEnd?: number;
|
||||||
@@ -51,92 +44,230 @@ type VerseIndexEntry = {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type ChapterViewerProps = {
|
type ChapterViewerProps = {
|
||||||
initialVerses: Verse[];
|
/** The first verse (SSR'd), shown before any client-side fetch */
|
||||||
hasMore: boolean;
|
initialVerse: Verse;
|
||||||
totalPages: number;
|
/** The verse number of the initial verse */
|
||||||
book: string;
|
initialVerseNum: number;
|
||||||
division: string | null;
|
/** Lightweight index of ALL verses in the chapter (for the sidebar) */
|
||||||
chapter: number;
|
|
||||||
limit: number;
|
|
||||||
showBengali?: boolean;
|
|
||||||
verseIndex: VerseIndexEntry[];
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Navigation buttons (reused at top and bottom)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function VerseNav({
|
||||||
|
currentIdx,
|
||||||
|
total,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
verseIndex,
|
||||||
|
}: {
|
||||||
|
currentIdx: number;
|
||||||
|
total: number;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
verseIndex: VerseIndexEntry[];
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={onPrev}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
<span className="hidden sm:inline">Previous</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Verse {label} of {total}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={onNext}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Next</span>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verse card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function VerseCard({
|
||||||
|
verse,
|
||||||
|
showBengali,
|
||||||
|
}: {
|
||||||
|
verse: Verse;
|
||||||
|
showBengali: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ChapterViewer
|
// 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({
|
export function ChapterViewer({
|
||||||
initialVerses,
|
initialVerse,
|
||||||
hasMore: initialHasMore,
|
initialVerseNum,
|
||||||
totalPages: initialTotalPages,
|
verseIndex,
|
||||||
book,
|
book,
|
||||||
division,
|
division,
|
||||||
chapter,
|
chapter,
|
||||||
limit,
|
|
||||||
showBengali = false,
|
showBengali = false,
|
||||||
verseIndex,
|
|
||||||
}: ChapterViewerProps) {
|
}: ChapterViewerProps) {
|
||||||
// ------ Verse data state ------
|
const router = useRouter();
|
||||||
const [verses, setVerses] = useState<Verse[]>(initialVerses);
|
const searchParams = useSearchParams();
|
||||||
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 ------
|
// Verse cache: verseNum → Verse data
|
||||||
const [activeVerse, setActiveVerse] = useState<number | null>(null);
|
const [cache, setCache] = useState<Map<number, Verse>>(() => {
|
||||||
const [loadedVerses, setLoadedVerses] = useState<Set<number>>(() => {
|
const map = new Map<number, Verse>();
|
||||||
const set = new Set<number>();
|
map.set(initialVerseNum, initialVerse);
|
||||||
for (const v of initialVerses) set.add(v.verse);
|
return map;
|
||||||
return set;
|
|
||||||
});
|
});
|
||||||
const [pendingVerse, setPendingVerse] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Reset when route changes (different chapter)
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Current verse number from URL (or default to initial)
|
||||||
|
const currentVerseNum = useMemo(() => {
|
||||||
|
const param = searchParams.get("verse");
|
||||||
|
if (param) {
|
||||||
|
const num = parseInt(param);
|
||||||
|
if (!isNaN(num) && verseIndex.some((v) => v.verse === num)) {
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return initialVerseNum;
|
||||||
|
}, [searchParams, initialVerseNum, verseIndex]);
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
setVerses(initialVerses);
|
const map = new Map<number, Verse>();
|
||||||
setHasMorePages(initialHasMore);
|
map.set(initialVerseNum, initialVerse);
|
||||||
setHighestPageFetched(1);
|
setCache(map);
|
||||||
setTotalPagesKnown(initialTotalPages);
|
}, [initialVerse, initialVerseNum]);
|
||||||
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 ----
|
// Fetch a verse if it's not in the cache
|
||||||
useEffect(() => {
|
const fetchVerse = useCallback(
|
||||||
const set = new Set<number>();
|
async (verseNum: number) => {
|
||||||
for (const v of verses) set.add(v.verse);
|
if (cache.has(verseNum)) return cache.get(verseNum)!;
|
||||||
setLoadedVerses(set);
|
|
||||||
}, [verses]);
|
|
||||||
|
|
||||||
// =====================================================================
|
setLoading(true);
|
||||||
// Fetching
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
const fetchPage = useCallback(
|
|
||||||
async (page: number): Promise<PaginatedResponse | null> => {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
chapter: String(chapter),
|
chapter: String(chapter),
|
||||||
page: String(page),
|
verse: String(verseNum),
|
||||||
limit: String(limit),
|
|
||||||
});
|
});
|
||||||
if (division !== null) {
|
if (division !== null) {
|
||||||
params.set("division", division);
|
params.set("division", division);
|
||||||
@@ -145,203 +276,102 @@ export function ChapterViewer({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/books/${book}/verses?${params}`);
|
const res = await fetch(`/api/books/${book}/verses?${params}`);
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
return await res.json();
|
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) {
|
} catch (err) {
|
||||||
console.error(`Failed to fetch page ${page}:`, err);
|
console.error("Failed to fetch verse:", err);
|
||||||
return null;
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
[book, division, chapter, limit],
|
[cache, book, division, chapter],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch the next single page (for infinite scroll)
|
// Navigate to a verse — update the URL and fetch if needed
|
||||||
const fetchNextPage = useCallback(async () => {
|
const navigateTo = useCallback(
|
||||||
if (fetchingRef.current || !hasMorePages) return;
|
async (verseNum: number) => {
|
||||||
fetchingRef.current = true;
|
// Update URL with shallow navigation
|
||||||
setLoading(true);
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("verse", String(verseNum));
|
||||||
|
router.push(`?${params.toString()}`, { scroll: false });
|
||||||
|
|
||||||
const nextPage = highestPageFetched + 1;
|
// Fetch if not cached
|
||||||
const data = await fetchPage(nextPage);
|
if (!cache.has(verseNum)) {
|
||||||
|
await fetchVerse(verseNum);
|
||||||
|
}
|
||||||
|
|
||||||
if (data) {
|
// Scroll to top of the verse
|
||||||
setVerses((prev) => [...prev, ...data.verses]);
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
setHasMorePages(data.hasMore);
|
},
|
||||||
setHighestPageFetched(nextPage);
|
[searchParams, router, cache, fetchVerse],
|
||||||
setTotalPagesKnown(data.totalPages);
|
);
|
||||||
|
|
||||||
|
// Fetch current verse on mount/URL change if not cached
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cache.has(currentVerseNum)) {
|
||||||
|
fetchVerse(currentVerseNum);
|
||||||
}
|
}
|
||||||
|
}, [currentVerseNum, cache, fetchVerse]);
|
||||||
|
|
||||||
setLoading(false);
|
// Prefetch adjacent verses
|
||||||
fetchingRef.current = false;
|
useEffect(() => {
|
||||||
}, [hasMorePages, highestPageFetched, fetchPage]);
|
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]);
|
||||||
|
|
||||||
// Fetch all pages up to the one containing targetVerse
|
// Keyboard navigation
|
||||||
const requestVerse = useCallback(
|
useEffect(() => {
|
||||||
async (targetVerse: number) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
// Already loaded?
|
if (
|
||||||
if (verses.some((v) => v.verse === targetVerse)) {
|
e.target instanceof HTMLInputElement ||
|
||||||
// Just scroll to it
|
e.target instanceof HTMLTextAreaElement
|
||||||
const el = document.getElementById(`verse-${targetVerse}`);
|
)
|
||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
return;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [currentIdx, verseIndex, navigateTo]);
|
||||||
|
|
||||||
// Watch for new verse cards from infinite scroll
|
const goPrev = useCallback(() => {
|
||||||
const mutationObserver = new MutationObserver((mutations) => {
|
if (currentIdx > 0) navigateTo(verseIndex[currentIdx - 1].verse);
|
||||||
let hasNew = false;
|
}, [currentIdx, verseIndex, navigateTo]);
|
||||||
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");
|
const goNext = useCallback(() => {
|
||||||
if (main) {
|
if (currentIdx < verseIndex.length - 1)
|
||||||
mutationObserver.observe(main, { childList: true, subtree: true });
|
navigateTo(verseIndex[currentIdx + 1].verse);
|
||||||
}
|
}, [currentIdx, verseIndex, navigateTo]);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -352,23 +382,20 @@ export function ChapterViewer({
|
|||||||
</h2>
|
</h2>
|
||||||
<nav className="grid grid-cols-3 gap-0.5">
|
<nav className="grid grid-cols-3 gap-0.5">
|
||||||
{verseIndex.map((v) => {
|
{verseIndex.map((v) => {
|
||||||
const isLoaded = loadedVerses.has(v.verse);
|
const isActive = currentVerseNum === v.verse;
|
||||||
const isActive = activeVerse === v.verse;
|
|
||||||
const isPending = pendingVerse === v.verse;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={v.verse}
|
key={v.verse}
|
||||||
href={`#verse-${v.verse}`}
|
href={`?verse=${v.verse}`}
|
||||||
onClick={(e) => handleIndexClick(e, v.verse)}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateTo(v.verse);
|
||||||
|
}}
|
||||||
className={`px-2 py-1 rounded-md text-sm text-center transition-colors ${
|
className={`px-2 py-1 rounded-md text-sm text-center transition-colors ${
|
||||||
isPending
|
isActive
|
||||||
? "bg-primary/20 text-primary animate-pulse font-medium"
|
? "bg-primary/10 text-primary font-medium"
|
||||||
: isActive
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
? "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}
|
{v.verseEnd ? `${v.verse}–${v.verseEnd}` : v.verse}
|
||||||
@@ -378,98 +405,39 @@ export function ChapterViewer({
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ---- Main: Verse Cards ---- */}
|
{/* ---- Main: Single Verse ---- */}
|
||||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-12">
|
<main className="flex-1 min-w-0 px-6 py-8 space-y-6">
|
||||||
{verses.map((verse) => (
|
{/* Top navigation */}
|
||||||
<Card
|
<VerseNav
|
||||||
key={`${verse.book}-${verse.division}-${verse.chapter}-${verse.verse}`}
|
currentIdx={currentIdx}
|
||||||
id={`verse-${verse.verse}`}
|
total={verseIndex.length}
|
||||||
className="scroll-mt-24"
|
onPrev={goPrev}
|
||||||
>
|
onNext={goNext}
|
||||||
<CardHeader className="border-b border-border bg-muted/50 py-3">
|
verseIndex={verseIndex}
|
||||||
<Badge variant="secondary">{verse.reference}</Badge>
|
/>
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-5 pt-5">
|
{/* Verse content */}
|
||||||
{/* Bengali (CC only) */}
|
{loading && !currentVerse ? (
|
||||||
{showBengali && verse.bengali && (
|
<div className="flex items-center justify-center py-24 text-muted-foreground gap-2">
|
||||||
<div className="text-center">
|
<Loader2 className="size-5 animate-spin" />
|
||||||
<p className="text-xl leading-relaxed whitespace-pre-line font-serif text-green-800 dark:text-green-400">
|
<span>Loading verse…</span>
|
||||||
{verse.bengali}
|
</div>
|
||||||
</p>
|
) : currentVerse ? (
|
||||||
</div>
|
<VerseCard verse={currentVerse} showBengali={showBengali} />
|
||||||
)}
|
) : (
|
||||||
|
<div className="text-center py-24 text-muted-foreground">
|
||||||
{/* Devanagari */}
|
Verse not found.
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom navigation */}
|
||||||
|
<VerseNav
|
||||||
|
currentIdx={currentIdx}
|
||||||
|
total={verseIndex.length}
|
||||||
|
onPrev={goPrev}
|
||||||
|
onNext={goNext}
|
||||||
|
verseIndex={verseIndex}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getChapterVerses, getChapterVerseIndex } from "@/lib/api";
|
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
||||||
|
|
||||||
type ChapterInfo = {
|
type ChapterInfo = {
|
||||||
@@ -19,11 +19,13 @@ type CantoInfo = {
|
|||||||
|
|
||||||
export default async function SbChapterPage({
|
export default async function SbChapterPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ canto: string; chapter: string }>;
|
params: Promise<{ canto: string; chapter: string }>;
|
||||||
|
searchParams: Promise<{ verse?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { canto, chapter } = await params;
|
const { canto, chapter } = await params;
|
||||||
const PAGE_SIZE = 20;
|
const { verse: verseParam } = await searchParams;
|
||||||
|
|
||||||
const meta = await getBookMeta("sb");
|
const meta = await getBookMeta("sb");
|
||||||
if (!meta) notFound();
|
if (!meta) notFound();
|
||||||
@@ -37,12 +39,20 @@ export default async function SbChapterPage({
|
|||||||
);
|
);
|
||||||
if (!chapterInfo) notFound();
|
if (!chapterInfo) notFound();
|
||||||
|
|
||||||
const [verseIndex, firstPage] = await Promise.all([
|
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
|
||||||
getChapterVerseIndex("sb", canto, parseInt(chapter)),
|
|
||||||
getChapterVerses("sb", canto, parseInt(chapter), 1, PAGE_SIZE),
|
|
||||||
]);
|
|
||||||
if (verseIndex.length === 0) notFound();
|
if (verseIndex.length === 0) notFound();
|
||||||
|
|
||||||
|
const requestedVerse = verseParam
|
||||||
|
? parseInt(verseParam)
|
||||||
|
: verseIndex[0].verse;
|
||||||
|
const verse = await getSingleVerse(
|
||||||
|
"sb",
|
||||||
|
canto,
|
||||||
|
parseInt(chapter),
|
||||||
|
requestedVerse,
|
||||||
|
);
|
||||||
|
if (!verse) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<PageHeader sticky wide>
|
<PageHeader sticky wide>
|
||||||
@@ -59,19 +69,17 @@ export default async function SbChapterPage({
|
|||||||
|
|
||||||
<div className="w-full md:max-w-5xl mx-auto flex">
|
<div className="w-full md:max-w-5xl mx-auto flex">
|
||||||
<ChapterViewer
|
<ChapterViewer
|
||||||
initialVerses={firstPage.verses}
|
initialVerse={verse}
|
||||||
hasMore={firstPage.hasMore}
|
initialVerseNum={requestedVerse}
|
||||||
totalPages={firstPage.totalPages}
|
|
||||||
book="sb"
|
|
||||||
division={canto}
|
|
||||||
chapter={parseInt(chapter)}
|
|
||||||
limit={PAGE_SIZE}
|
|
||||||
showBengali={false}
|
|
||||||
verseIndex={verseIndex.map((v) => ({
|
verseIndex={verseIndex.map((v) => ({
|
||||||
verse: v.verse,
|
verse: v.verse,
|
||||||
verseEnd: v.verseEnd ?? undefined,
|
verseEnd: v.verseEnd ?? undefined,
|
||||||
reference: v.reference,
|
reference: v.reference,
|
||||||
}))}
|
}))}
|
||||||
|
book="sb"
|
||||||
|
division={canto}
|
||||||
|
chapter={parseInt(chapter)}
|
||||||
|
showBengali={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
@@ -154,6 +154,26 @@ export async function getChapterVerses(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single verse
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single verse by its exact coordinates.
|
||||||
|
*/
|
||||||
|
export async function getSingleVerse(
|
||||||
|
book: string,
|
||||||
|
division: string | null,
|
||||||
|
chapter: number,
|
||||||
|
verse: number,
|
||||||
|
): Promise<VerseRecord | null> {
|
||||||
|
const result = await prisma.verse.findFirst({
|
||||||
|
where: { book, division, chapter, verse },
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as unknown as VerseRecord | null;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Glossary
|
// Glossary
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user