feat: allow path params for verse navigation

This commit is contained in:
gosangam
2026-04-15 13:41:34 +05:30
parent c0a00d4c04
commit abb7cc49d0
19 changed files with 1462 additions and 261 deletions
+77
View File
@@ -0,0 +1,77 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
type BgMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const meta = await getBookMeta("bg");
if (!meta) notFound();
const bgMeta = meta as unknown as BgMeta;
const chapterInfo = bgMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getSingleVerse(
"bg",
null,
parseInt(chapter),
requestedVerse,
);
if (!verse) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/bg"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Bhagavad-gītā
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="bg"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/bg/${chapter}`}
showBengali={false}
/>
</div>
</div>
);
}
+6 -71
View File
@@ -1,81 +1,16 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
type BgMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
import { getChapterVerseIndex } from "@/lib/api";
export default async function ChapterPage({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<{ verse?: string }>;
}) {
const { chapter } = await params;
const { verse: verseParam } = await searchParams;
const meta = await getBookMeta("bg");
if (!meta) notFound();
const bgMeta = meta as unknown as BgMeta;
const chapterInfo = bgMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
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 (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/bg"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Bhagavad-gītā
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="bg"
division={null}
chapter={parseInt(chapter)}
showBengali={false}
/>
</div>
</div>
);
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/bg/${chapter}/${verseIndex[0].verse}`);
}
@@ -0,0 +1,84 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export default async function CcVersePage({
params,
}: {
params: Promise<{ lila: string; chapter: string; verse: string }>;
}) {
const { lila, chapter, verse } = await params;
const meta = await getBookMeta("cc");
if (!meta) notFound();
const lilas = meta.lilas as LilaInfo[] | undefined;
const lilaInfo = lilas?.find((l) => l.key === lila);
if (!lilaInfo) notFound();
const chapterInfo = lilaInfo.chapters.find(
(ch) => ch.number === parseInt(chapter),
);
if (!chapterInfo) notFound();
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verse);
const verseData = await getSingleVerse(
"cc",
lila,
parseInt(chapter),
requestedVerse,
);
if (!verseData) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href={`/books/cc/${lila}`}
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; {lilaInfo.name}
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {chapterInfo.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verseData}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="cc"
division={lila}
chapter={parseInt(chapter)}
showBengali={true}
basePath={`/books/cc/${lila}/${chapter}`}
/>
</div>
</div>
);
}
+6 -78
View File
@@ -1,88 +1,16 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
import { getChapterVerseIndex } from "@/lib/api";
export default async function CcChapterPage({
params,
searchParams,
}: {
params: Promise<{ lila: string; chapter: string }>;
searchParams: Promise<{ verse?: string }>;
}) {
const { lila, chapter } = await params;
const { verse: verseParam } = await searchParams;
const meta = await getBookMeta("cc");
if (!meta) notFound();
const lilas = meta.lilas as LilaInfo[] | undefined;
const lilaInfo = lilas?.find((l) => l.key === lila);
if (!lilaInfo) notFound();
const chapterInfo = lilaInfo.chapters.find(
(ch) => ch.number === parseInt(chapter),
);
if (!chapterInfo) notFound();
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 (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href={`/books/cc/${lila}`}
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; {lilaInfo.name}
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {chapterInfo.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="cc"
division={lila}
chapter={parseInt(chapter)}
showBengali={true}
/>
</div>
</div>
);
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/cc/${lila}/${chapter}/${verseIndex[0].verse}`);
}
+14 -22
View File
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
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";
@@ -58,6 +58,8 @@ type ChapterViewerProps = {
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;
};
// ---------------------------------------------------------------------------
@@ -217,9 +219,9 @@ export function ChapterViewer({
division,
chapter,
showBengali = false,
basePath,
}: ChapterViewerProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Verse cache: verseNum → Verse data
const [cache, setCache] = useState<Map<number, Verse>>(() => {
@@ -230,17 +232,9 @@ export function ChapterViewer({
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 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(
@@ -304,23 +298,21 @@ export function ChapterViewer({
[cache, book, division, chapter],
);
// Navigate to a verse — update the URL and fetch if needed
// Navigate to a verse — update the URL path and fetch if needed
const navigateTo = useCallback(
async (verseNum: number) => {
// Update URL with shallow navigation
const params = new URLSearchParams(searchParams.toString());
params.set("verse", String(verseNum));
router.push(`?${params.toString()}`, { scroll: false });
// Fetch if not cached
// 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" });
},
[searchParams, router, cache, fetchVerse],
[basePath, router, cache, fetchVerse],
);
// Fetch current verse on mount/URL change if not cached
@@ -390,7 +382,7 @@ export function ChapterViewer({
return (
<a
key={v.verse}
href={`?verse=${v.verse}`}
href={`${basePath}/${v.verse}`}
onClick={(e) => {
e.preventDefault();
navigateTo(v.verse);
+16 -2
View File
@@ -16,7 +16,7 @@ type ChapterEntry = { totalVerses: number };
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
type LilaEntry = { totalChapters: number; chapters: ChapterEntry[] };
interface BgMeta extends BookMeta {
interface ChapterBookMeta extends BookMeta {
totalChapters: number;
chapters: ChapterEntry[];
}
@@ -49,7 +49,7 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
const author = data.author as string;
if (slug === "bg") {
const bg = data as BgMeta;
const bg = data as ChapterBookMeta;
const totalVerses = bg.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
@@ -62,6 +62,20 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
totalVerses,
href: "/books/bg",
});
} else if (slug === "sb1962") {
const sb62 = data as ChapterBookMeta;
const totalVerses = sb62.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
books.push({
slug,
title,
author,
description: `${sb62.totalChapters} chapters · First Canto`,
totalVerses,
href: "/books/sb1962",
});
} else if (slug === "sb") {
const sb = data as SbMeta;
const totalChapters = sb.cantos.reduce(
@@ -0,0 +1,83 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export default async function SbVersePage({
params,
}: {
params: Promise<{ canto: string; chapter: string; verse: string }>;
}) {
const { canto, chapter, verse } = await params;
const meta = await getBookMeta("sb");
if (!meta) notFound();
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
const cantoInfo = cantos.find((c) => c.number === parseInt(canto));
if (!cantoInfo) notFound();
const chapterInfo = cantoInfo.chapters.find(
(ch) => ch.number === parseInt(chapter),
);
if (!chapterInfo) notFound();
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verse);
const verseData = await getSingleVerse(
"sb",
canto,
parseInt(chapter),
requestedVerse,
);
if (!verseData) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href={`/books/sb/${canto}`}
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Canto {cantoInfo.number}: {cantoInfo.title}
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {chapterInfo.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verseData}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="sb"
division={canto}
chapter={parseInt(chapter)}
showBengali={false}
basePath={`/books/sb/${canto}/${chapter}`}
/>
</div>
</div>
);
}
+6 -77
View File
@@ -1,87 +1,16 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
import { getChapterVerseIndex } from "@/lib/api";
export default async function SbChapterPage({
params,
searchParams,
}: {
params: Promise<{ canto: string; chapter: string }>;
searchParams: Promise<{ verse?: string }>;
}) {
const { canto, chapter } = await params;
const { verse: verseParam } = await searchParams;
const meta = await getBookMeta("sb");
if (!meta) notFound();
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
const cantoInfo = cantos.find((c) => c.number === parseInt(canto));
if (!cantoInfo) notFound();
const chapterInfo = cantoInfo.chapters.find(
(ch) => ch.number === parseInt(chapter),
);
if (!chapterInfo) notFound();
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
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 (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href={`/books/sb/${canto}`}
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Canto {cantoInfo.number}: {cantoInfo.title}
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {chapterInfo.title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="sb"
division={canto}
chapter={parseInt(chapter)}
showBengali={false}
/>
</div>
</div>
);
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/sb/${canto}/${chapter}/${verseIndex[0].verse}`);
}
@@ -0,0 +1,81 @@
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
type Sb1962Meta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const meta = await getBookMeta("sb1962");
if (!meta) notFound();
const sb1962Meta = meta as unknown as Sb1962Meta;
const chapterInfo = sb1962Meta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getChapterVerseIndex(
"sb1962",
null,
parseInt(chapter),
);
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getSingleVerse(
"sb1962",
null,
parseInt(chapter),
requestedVerse,
);
if (!verse) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader sticky wide>
<Link
href="/books/sb1962"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrīmad-Bhāgavatam (1962)
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="w-full md:max-w-7xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="sb1962"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/sb1962/${chapter}`}
showBengali={false}
/>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
export default async function ChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
const verseIndex = await getChapterVerseIndex(
"sb1962",
null,
parseInt(chapter),
);
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/sb1962/${chapter}/${verseIndex[0].verse}`);
}
+69
View File
@@ -0,0 +1,69 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
export default async function Sb1962Page() {
const meta = await getBookMeta("sb1962");
if (!meta) notFound();
const intro = meta as unknown as {
book: string;
title: string;
author: string;
description?: string;
totalChapters: number;
chapters: ChapterInfo[];
};
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{intro.title}
</h1>
<p className="mt-1 text-muted-foreground">{intro.author}</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{intro.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/sb1962/${ch.number}`}
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">{ch.title}</p>
</div>
<span className="text-sm text-muted-foreground shrink-0 ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}