diff --git a/.gitignore b/.gitignore
index 5fbbaa9d..a3c20187 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,4 @@ next-env.d.ts
public/rtfs
/lib/generated/prisma
+app/lib
diff --git a/app/api/books/[book]/verses/route.ts b/app/api/books/[book]/verses/route.ts
index b4fc6704..11eecd8a 100644
--- a/app/api/books/[book]/verses/route.ts
+++ b/app/api/books/[book]/verses/route.ts
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
-const DEFAULT_PAGE_SIZE = 20;
-
export async function GET(
req: Request,
{ params }: { params: Promise<{ book: string }> },
@@ -11,6 +9,7 @@ export async function GET(
const url = new URL(req.url);
const division = url.searchParams.get("division");
const chapterStr = url.searchParams.get("chapter");
+ const verseStr = url.searchParams.get("verse");
const indexOnly = url.searchParams.get("indexOnly") === "true";
const pageStr = url.searchParams.get("page");
const limitStr = url.searchParams.get("limit");
@@ -31,8 +30,21 @@ export async function GET(
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 ---
- // Returns all verses but only the fields needed for navigation
if (indexOnly) {
const verses = await prisma.verse.findMany({
where,
@@ -49,10 +61,7 @@ export async function GET(
// --- Paginated mode: return full verse data in pages ---
const page = Math.max(1, parseInt(pageStr || "1"));
- const limit = Math.min(
- 100,
- Math.max(1, parseInt(limitStr || String(DEFAULT_PAGE_SIZE))),
- );
+ const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
const skip = (page - 1) * limit;
const [verses, total] = await Promise.all([
diff --git a/app/books/bg/[chapter]/page.tsx b/app/books/bg/[chapter]/page.tsx
index 76710a60..4e2e9d1e 100644
--- a/app/books/bg/[chapter]/page.tsx
+++ b/app/books/bg/[chapter]/page.tsx
@@ -1,7 +1,7 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
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";
type BgMeta = {
@@ -14,11 +14,13 @@ type BgMeta = {
export default async function ChapterPage({
params,
+ searchParams,
}: {
params: Promise<{ chapter: string }>;
+ searchParams: Promise<{ verse?: string }>;
}) {
const { chapter } = await params;
- const PAGE_SIZE = 20;
+ const { verse: verseParam } = await searchParams;
const meta = await getBookMeta("bg");
if (!meta) notFound();
@@ -31,12 +33,20 @@ export default async function ChapterPage({
const title: string = chapterInfo.title;
- const [verseIndex, firstPage] = await Promise.all([
- getChapterVerseIndex("bg", null, parseInt(chapter)),
- getChapterVerses("bg", null, parseInt(chapter), 1, PAGE_SIZE),
- ]);
+ const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
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 (
@@ -53,19 +63,17 @@ export default async function ChapterPage({
({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
+ book="bg"
+ division={null}
+ chapter={parseInt(chapter)}
+ showBengali={false}
/>
diff --git a/app/books/cc/[lila]/[chapter]/page.tsx b/app/books/cc/[lila]/[chapter]/page.tsx
index 57cc980d..5b545c7f 100644
--- a/app/books/cc/[lila]/[chapter]/page.tsx
+++ b/app/books/cc/[lila]/[chapter]/page.tsx
@@ -1,7 +1,7 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
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";
type ChapterInfo = {
@@ -20,11 +20,13 @@ type LilaInfo = {
export default async function CcChapterPage({
params,
+ searchParams,
}: {
params: Promise<{ lila: string; chapter: string }>;
+ searchParams: Promise<{ verse?: string }>;
}) {
const { lila, chapter } = await params;
- const PAGE_SIZE = 20;
+ const { verse: verseParam } = await searchParams;
const meta = await getBookMeta("cc");
if (!meta) notFound();
@@ -38,12 +40,20 @@ export default async function CcChapterPage({
);
if (!chapterInfo) notFound();
- const [verseIndex, firstPage] = await Promise.all([
- getChapterVerseIndex("cc", lila, parseInt(chapter)),
- getChapterVerses("cc", lila, parseInt(chapter), 1, PAGE_SIZE),
- ]);
+ const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
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 (
@@ -60,19 +70,17 @@ export default async function CcChapterPage({
({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
+ book="cc"
+ division={lila}
+ chapter={parseInt(chapter)}
+ showBengali={true}
/>
diff --git a/app/books/chapter-viewer.tsx b/app/books/chapter-viewer.tsx
index f821e534..23fb6bd3 100644
--- a/app/books/chapter-viewer.tsx
+++ b/app/books/chapter-viewer.tsx
@@ -1,9 +1,11 @@
"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 { Badge } from "@/components/ui/badge";
-import { Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
// ---------------------------------------------------------------------------
// Types
@@ -31,15 +33,6 @@ type Verse = {
purport: string[];
};
-type PaginatedResponse = {
- verses: Verse[];
- total: number;
- page: number;
- limit: number;
- totalPages: number;
- hasMore: boolean;
-};
-
type VerseIndexEntry = {
verse: number;
verseEnd?: number;
@@ -51,92 +44,230 @@ type VerseIndexEntry = {
// ---------------------------------------------------------------------------
type ChapterViewerProps = {
- initialVerses: Verse[];
- hasMore: boolean;
- totalPages: number;
- book: string;
- division: string | null;
- chapter: number;
- limit: number;
- showBengali?: boolean;
+ /** 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;
};
+// ---------------------------------------------------------------------------
+// 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 (
+
+
+
+
+ Verse {label} of {total}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Verse card
+// ---------------------------------------------------------------------------
+
+function VerseCard({
+ verse,
+ showBengali,
+}: {
+ verse: Verse;
+ showBengali: boolean;
+}) {
+ return (
+
+
+ {verse.reference}
+
+
+
+ {/* Bengali (CC only) */}
+ {showBengali && 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 */}
+
+
+ {/* Purport */}
+ {verse.purport.length > 0 && (
+
+
+ Purport
+
+
+ {verse.purport.map((p, i) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// 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,
+ initialVerse,
+ initialVerseNum,
+ verseIndex,
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);
+ const router = useRouter();
+ const searchParams = useSearchParams();
- // ------ 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;
+ // Verse cache: verseNum → Verse data
+ const [cache, setCache] = useState