feat: add policy pages

This commit is contained in:
gosangam
2026-06-02 01:57:08 +05:30
parent 0352cb2003
commit 91d5d272d2
168 changed files with 1326 additions and 81 deletions
+299
View File
@@ -0,0 +1,299 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getBbdContent,
getBbdSidebarItems,
type BbdChapter,
type BbdSidebarItem,
} from "@/lib/bbd";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("bbd", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["bbd"], quote: hlQuote });
}
return ogForChapter("bbd", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function BbdChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getBbdContent(slug);
if (!content) notFound();
const sidebarItems = await getBbdSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as BbdChapter).chapter === "number" &&
(content as BbdChapter).chapter !== null;
const chapterNum = isChapter ? (content as BbdChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bbd"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Beyond Birth and Death
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<BbdSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<BbdNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<BbdNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function BbdSidebar({
items,
currentSlug,
}: {
items: BbdSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: BbdSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/bbd/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function BbdNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: BbdSidebarItem | null;
next: BbdSidebarItem | null;
sidebarItems: BbdSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/bbd/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/bbd/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Beyond Birth and Death"
/>
{prev ? (
<Link
href={`/books/bbd/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/bbd/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBbdMeta, getBbdIntroSegments } from "@/lib/bbd";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("bbd");
}
export default async function BbdPage() {
const meta = await getBbdMeta();
if (!meta) notFound();
const introSegments = await getBbdIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/bbd/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bbd/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
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/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"bg",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Bhagavad-gītā As It Is",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("bg", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
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();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/bg/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<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="max-w-5xl 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}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({ title: BOOK_NAMES["bg"], detail: `Chapter ${chapter}` });
}
export default async function ChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/bg/${chapter}/${verseIndex[0].verse}`);
}
+74
View File
@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
export function generateMetadata(): Metadata {
return ogForBook("bg");
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
export default async function BgPage() {
const meta = await getBookMeta("bg");
if (!meta) notFound();
const intro = meta as unknown as {
book: string;
title: string;
author: 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-5xl 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/bg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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 sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getBkContent,
getBkSidebarItems,
type BkChapter,
type BkSidebarItem,
} from "@/lib/bk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("bk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["bk"], quote: hlQuote });
}
return ogForChapter("bk", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function BkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getBkContent(slug);
if (!content) notFound();
const sidebarItems = await getBkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const chapterNum = (content as BkChapter).chapter ?? null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Bhagavāner Kathā
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<BkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<BkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<BkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function BkSidebar({
items,
currentSlug,
}: {
items: BkSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: BkSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/bk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function BkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: BkSidebarItem | null;
next: BkSidebarItem | null;
sidebarItems: BkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/bk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Bhagavāner Kathā"
/>
{prev ? (
<Link
href={`/books/bk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/bk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBkMeta } from "@/lib/bk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("bk");
}
export default async function BkPage() {
const meta = await getBkMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
import { ExtractedVerseCount } from "./extracted-verse-count";
export type BookInfo = {
slug: string;
title: string;
author: string;
description: string;
contentDescription: string;
totalVerses: number;
href: string;
};
/**
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
*
* Uses Unicode NFD decomposition to split base characters from
* combining marks, then removes the marks. Also handles special
* IAST characters that don't decompose cleanly via NFD.
*/
function normalize(text: string): string {
return (
text
.toLowerCase()
// Handle IAST characters that NFD doesn't decompose into ASCII base
.replace(/ṛ/g, "r")
.replace(/ṝ/g, "r")
.replace(/ṣ/g, "s")
.replace(/ś/g, "s")
.replace(/ṭ/g, "t")
.replace(/ḍ/g, "d")
.replace(/ṇ/g, "n")
.replace(/ñ/g, "n")
.replace(/ṅ/g, "n")
.replace(/ḥ/g, "h")
.replace(/ṁ/g, "m")
.replace(/ṃ/g, "m")
.replace(/ḷ/g, "l")
// NFD decompose + strip combining marks (handles ā→a, ī→i, ū→u, etc.)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
);
}
export function BooksGrid({ books }: { books: BookInfo[] }) {
const [query, setQuery] = useState("");
const normalizedBooks = useMemo(
() =>
books.map((book) => ({
book,
searchText: normalize(
`${book.title} ${book.contentDescription} ${book.description} ${book.slug}`,
),
})),
[books],
);
const filtered = useMemo(() => {
if (!query.trim()) return books;
const q = normalize(query.trim());
return normalizedBooks
.filter((entry) => entry.searchText.includes(q))
.map((entry) => entry.book);
}, [books, normalizedBooks, query]);
return (
<div className="space-y-6">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search books by name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Results count when searching */}
{query.trim() && (
<p className="text-sm text-muted-foreground">
{filtered.length === 0
? "No books found"
: `${filtered.length} book${filtered.length === 1 ? "" : "s"} found`}
</p>
)}
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((book) => (
<Link key={book.slug} href={book.href} className="block h-full">
<Card
className={cn(
"h-full transition-all hover:shadow-md hover:ring-foreground/20",
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold leading-snug">
{book.title}
</CardTitle>
{book.contentDescription && (
<CardDescription className="text-xs line-clamp-2">
{book.contentDescription}
</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{book.description && (
<Badge variant="secondary" className="text-xs">
{book.description}
</Badge>
)}
{book.totalVerses > 0 && (
<ExtractedVerseCount
count={book.totalVerses}
label="verses"
className="text-muted-foreground text-xs"
/>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,215 @@
import type { Metadata } from "next";
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
type BsMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("bs");
if (meta) return meta as unknown as BsMeta;
const introPath = path.join(BS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("bs", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("bs", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `bs-${chapter}-${verse}`,
book: "bs",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getVerseWithFallback(parseInt(chapter), parseInt(verse));
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrī Brahma-saṁhitā",
detail: `Chapter ${chapter}, Verse ${parseInt(verse)}`,
quote,
});
}
return ogForVerse("bs", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const bsMeta = await getMetaWithFallback();
if (!bsMeta) notFound();
const chapterInfo = bsMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/bs/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bs"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrī Brahma-saṁhitā
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="bs"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/bs/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({ title: BOOK_NAMES["bs"], detail: `Chapter ${chapter}` });
}
export default async function BsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction, intro-foreword)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(BS_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Brahma-saṁhitā
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("bs", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/bs/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/bs/${chapter}/${verseIndex[0].verse}`);
}
+180
View File
@@ -0,0 +1,180 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
export function generateMetadata(): Metadata {
return ogForBook("bs");
}
type BsMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type BsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
async function getMetaWithFallback(): Promise<BsMeta | null> {
const meta = await getBookMeta("bs");
if (meta) return meta as unknown as BsMeta;
const introPath = path.join(BS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): BsIntroSegment[] {
const introDir = path.join(BS_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: BsIntroSegment[] = [];
const files = ["introduction.json", "foreword.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as BsIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export default async function BsPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
const foreword = introSegments.find((s) => s.slug === "foreword");
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{(introduction || foreword) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introduction && (
<li>
<Link
href="/books/bs/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{foreword && (
<li>
<Link
href="/books/bs/intro-foreword"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{foreword.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bs/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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 sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getCbhcContent,
getCbhcSidebarItems,
type CbhcChapter,
type CbhcSidebarItem,
} from "@/lib/cbhc";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("cbhc", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["cbhc"], quote: hlQuote });
}
return ogForChapter("cbhc", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function CbhcChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getCbhcContent(slug);
if (!content) notFound();
const sidebarItems = await getCbhcSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const chapterNum = (content as CbhcChapter).chapter ?? null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/cbhc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Caitanya-bhāgavata Commentary
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<CbhcSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<CbhcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<CbhcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function CbhcSidebar({
items,
currentSlug,
}: {
items: CbhcSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: CbhcSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/cbhc/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function CbhcNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: CbhcSidebarItem | null;
next: CbhcSidebarItem | null;
sidebarItems: CbhcSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/cbhc/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Śrī Caitanya-bhāgavata Commentary"
/>
{prev ? (
<Link
href={`/books/cbhc/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/cbhc/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getCbhcMeta } from "@/lib/cbhc";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("cbhc");
}
export default async function CbhcPage() {
const meta = await getCbhcMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/cbhc/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,139 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
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/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ lila: string; chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { lila, chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"cc",
lila,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
const bookTitle = "Śrī Caitanya-caritāmṛta";
return buildOgMeta({
title: bookTitle,
detail: `${lila.charAt(0).toUpperCase() + lila.slice(1)}, Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("cc", {
division: lila,
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
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();
// Build mobile nav groups from verse index
const mobileNavGroups: MobileNavGroup[] = [];
if (verseIndex.length > 0) {
mobileNavGroups.push({
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/cc/${lila}/${chapter}/${v.verse}`,
type: "verse",
})),
});
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<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="max-w-5xl 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}`}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
/>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { CC_LILAS } from "@/lib/constants";
import { buildOgMeta } from "@/lib/og";
export async function generateMetadata({
params,
}: {
params: Promise<{ lila: string; chapter: string }>;
}): Promise<Metadata> {
const { lila, chapter } = await params;
const lilaName = CC_LILAS[lila] || lila;
return buildOgMeta({
title: "Śrī Caitanya-caritāmṛta",
detail: `${lilaName}, Chapter ${chapter}`,
});
}
export default async function CcChapterPage({
params,
}: {
params: Promise<{ lila: string; chapter: string }>;
}) {
const { lila, chapter } = await params;
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/cc/${lila}/${chapter}/${verseIndex[0].verse}`);
}
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../../extracted-verse-count";
import { getBookMeta } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
export async function generateMetadata({
params,
}: {
params: Promise<{ lila: string }>;
}): Promise<Metadata> {
const { lila } = await params;
const lilaName = CC_LILAS[lila] || lila;
const bookTitle = BOOK_NAMES["cc"] || "Śrī Caitanya-caritāmṛta";
return buildOgMeta({
title: bookTitle,
detail: lilaName,
});
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export default async function LilaPage({
params,
}: {
params: Promise<{ lila: string }>;
}) {
const { lila } = await params;
const meta = await getBookMeta("cc");
if (!meta) notFound();
const lilas = meta.lilas as LilaInfo[];
const lilaInfo = lilas.find((l) => l.key === lila);
if (!lilaInfo) notFound();
const totalVerses = lilaInfo.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/cc"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrī Caitanya-caritāmṛta
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{lilaInfo.name}
</h1>
<p className="mt-1 text-muted-foreground">
{lilaInfo.totalChapters} chapters ·{" "}
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{lilaInfo.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/cc/${lila}/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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 sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ExtractedVerseCount } from "../extracted-verse-count";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export function generateMetadata(): Metadata {
return ogForBook("cc");
}
export default async function CcPage() {
const meta = await getBookMeta("cc");
if (!meta) {
return (
<div className="p-8 text-center text-muted-foreground">
Book metadata not found.
</div>
);
}
const lilas = meta.lilas as LilaInfo[];
const title = meta.title as string;
const author = meta.author as string;
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
<p className="mt-1 text-muted-foreground">{author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{lilas.map((lila) => {
const totalVerses = lila.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<li key={lila.key}>
<Link
href={`/books/cc/${lila.key}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
{lila.name}
</Badge>
<p className="font-medium text-foreground">
{lila.totalChapters} chapters
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount
count={totalVerses}
label="extracted verses"
/>
</span>
</Link>
</li>
);
})}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+471
View File
@@ -0,0 +1,471 @@
"use client";
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";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// 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 VerseIndexEntry = {
verse: number;
verseEnd?: number;
reference: string;
};
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
type ChapterViewerProps = {
/** 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;
/** Base path for verse URLs, e.g. "/books/bg/1" — verse number is appended */
basePath: string;
/** Mobile nav groups for the drawer */
mobileNavGroups?: MobileNavGroup[];
/** Book title for the mobile nav drawer header */
mobileNavBookTitle?: string;
};
// ---------------------------------------------------------------------------
// Navigation buttons (reused at top and bottom)
// ---------------------------------------------------------------------------
function VerseNav({
currentIdx,
total,
onPrev,
onNext,
verseIndex,
mobileNavGroups,
currentVerseSlug,
mobileNavBookTitle,
}: {
currentIdx: number;
total: number;
onPrev: () => void;
onNext: () => void;
verseIndex: VerseIndexEntry[];
mobileNavGroups?: MobileNavGroup[];
currentVerseSlug?: string;
mobileNavBookTitle?: string;
}) {
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">
<div className="flex items-center gap-2">
{mobileNavGroups && mobileNavGroups.length > 0 && (
<MobileNav
groups={mobileNavGroups}
currentSlug={currentVerseSlug ?? String(current?.verse ?? "")}
bookTitle={mobileNavBookTitle}
/>
)}
<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>
</div>
<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="">
<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-white 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-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
Synonyms
</h3>
<p className="text-base text-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-sm 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-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
Purport
</h3>
<div className="text-base text-foreground leading-relaxed space-y-3">
{verse.purport.map((p, i) => (
<p key={i} dangerouslySetInnerHTML={{ __html: p }} />
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// ChapterViewer
// ---------------------------------------------------------------------------
export function ChapterViewer({
initialVerse,
initialVerseNum,
verseIndex,
book,
division,
chapter,
showBengali = false,
basePath,
mobileNavGroups,
mobileNavBookTitle,
}: ChapterViewerProps) {
const router = useRouter();
// Verse cache: verseNum → Verse data
const [cache, setCache] = useState<Map<number, Verse>>(() => {
const map = new Map<number, Verse>();
map.set(initialVerseNum, initialVerse);
return map;
});
const [loading, setLoading] = useState(false);
// 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(
() => 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(() => {
const map = new Map<number, Verse>();
map.set(initialVerseNum, initialVerse);
setCache(map);
}, [initialVerse, initialVerseNum]);
// Fetch a verse if it's not in the cache
const fetchVerse = useCallback(
async (verseNum: number) => {
if (cache.has(verseNum)) return cache.get(verseNum)!;
setLoading(true);
const params = new URLSearchParams({
chapter: String(chapter),
verse: String(verseNum),
});
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}`);
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) {
console.error("Failed to fetch verse:", err);
} finally {
setLoading(false);
}
return null;
},
[cache, book, division, chapter],
);
// Navigate to a verse — update the URL path and fetch if needed
const navigateTo = useCallback(
async (verseNum: number) => {
// 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" });
},
[basePath, router, cache, fetchVerse],
);
// Fetch current verse on mount/URL change if not cached
useEffect(() => {
if (!cache.has(currentVerseNum)) {
fetchVerse(currentVerseNum);
}
}, [currentVerseNum, cache, fetchVerse]);
// Prefetch adjacent verses
useEffect(() => {
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]);
// Keyboard navigation
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
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);
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [currentIdx, verseIndex, navigateTo]);
const goPrev = useCallback(() => {
if (currentIdx > 0) navigateTo(verseIndex[currentIdx - 1].verse);
}, [currentIdx, verseIndex, navigateTo]);
const goNext = useCallback(() => {
if (currentIdx < verseIndex.length - 1)
navigateTo(verseIndex[currentIdx + 1].verse);
}, [currentIdx, verseIndex, navigateTo]);
return (
<>
{/* ---- Sidebar: Verse Index ---- */}
{/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for
the site footer so the sidebar doesn't slide behind it when the
page is scrolled to the bottom. (89 sticky-top + 76 bottom gap
= 165, hence calc(100vh - 165px).) */}
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-165px)] overflow-y-auto py-8 pr-4">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Verses
</h2>
<nav className="grid grid-cols-3 gap-1">
{verseIndex.map((v) => {
const isActive = currentVerseNum === v.verse;
const label = v.verseEnd
? `${v.verse}–${v.verseEnd}`
: String(v.verse);
return (
<a
key={v.verse}
href={`${basePath}/${v.verse}`}
onClick={(e) => {
e.preventDefault();
navigateTo(v.verse);
}}
className={`px-2 py-1 rounded-md text-sm tabular-nums transition-colors ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{label}
</a>
);
})}
</nav>
</aside>
{/* ---- Main: Single Verse ---- */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-6">
{/* Top navigation */}
<VerseNav
currentIdx={currentIdx}
total={verseIndex.length}
onPrev={goPrev}
onNext={goNext}
verseIndex={verseIndex}
mobileNavGroups={mobileNavGroups}
currentVerseSlug={String(currentVerseNum)}
mobileNavBookTitle={mobileNavBookTitle}
/>
{/* Verse content */}
{loading && !currentVerse ? (
<div className="flex items-center justify-center py-24 text-muted-foreground gap-2">
<Loader2 className="size-5 animate-spin" />
<span>Loading verse…</span>
</div>
) : currentVerse ? (
<VerseCard verse={currentVerse} showBengali={showBengali} />
) : (
<div className="text-center py-24 text-muted-foreground">
Verse not found.
</div>
)}
{/* Bottom navigation */}
<VerseNav
currentIdx={currentIdx}
total={verseIndex.length}
onPrev={goPrev}
onNext={goNext}
verseIndex={verseIndex}
mobileNavGroups={mobileNavGroups}
currentVerseSlug={String(currentVerseNum)}
mobileNavBookTitle={mobileNavBookTitle}
/>
</main>
</>
);
}
+331
View File
@@ -0,0 +1,331 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getDsContent,
getDsSidebarItems,
type DsChapter,
type DsSidebarItem,
} from "@/lib/ds";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ds", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ds"], quote: hlQuote });
}
return ogForChapter("ds", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function DsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getDsContent(slug);
if (!content) notFound();
const sidebarItems = await getDsSidebarItems();
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
const next =
currentIdx < navigableItems.length - 1
? navigableItems[currentIdx + 1]
: null;
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
const isSubChapter =
"parentChapter" in content &&
typeof (content as DsChapter).parentChapter === "number";
const parentChapterNum = isSubChapter
? (content as DsChapter).parentChapter
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ds"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Dialectic Spiritualism
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Chapter {parentChapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<DsSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<DsNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<DsNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function DsSidebar({
items,
currentSlug,
}: {
items: DsSidebarItem[];
currentSlug: string;
}) {
// Split items by section
const introItems = items.filter((i) => i.type === "intro-segment");
const parents = items.filter((i) => i.type === "parent");
const subChapters = items.filter((i) => i.type === "sub-chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters — grouped by parent */}
{parents.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{parents.map((parent) => (
<div key={parent.slug}>
{/* Parent heading — not a link, just a bold label */}
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
{parent.label}
</div>
{/* Sub-chapters indented */}
{subChapters
.filter((sub) => sub.parentChapter === parent.parentChapter)
.map((sub) => {
const isActive = sub.slug === currentSlug;
return (
<Link
key={sub.slug}
href={`/books/ds/${sub.slug}`}
className={cn(
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={sub.label}
>
{sub.label}
</Link>
);
})}
</div>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: DsSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ds/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function DsNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: DsSidebarItem | null;
next: DsSidebarItem | null;
sidebarItems: DsSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ds/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter(
(i) => i.type === "parent" || i.type === "sub-chapter",
);
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ds/${i.slug}`,
type: i.type,
parentChapter: i.parentChapter,
isParent: i.type === "parent",
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Dialectic Spiritualism"
/>
{prev ? (
<Link
href={`/books/ds/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ds/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getDsMeta, getDsIntroSegments } from "@/lib/ds";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ds");
}
export default async function DsPage() {
const meta = await getDsMeta();
if (!meta) notFound();
const introSegments = await getDsIntroSegments();
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">
{meta.title}
</h1>
{meta.subtitle && (
<p className="mt-1 text-lg text-muted-foreground">{meta.subtitle}</p>
)}
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ds/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters — hierarchical parent → sub-chapter structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{ch.number}. {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/ds/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getEjContent,
getEjSidebarItems,
type EjChapter,
type EjSidebarItem,
} from "@/lib/ej";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ej", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ej"], quote: hlQuote });
}
return ogForChapter("ej", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function EjChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getEjContent(slug);
if (!content) notFound();
const sidebarItems = await getEjSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as EjChapter).chapter === "number" &&
(content as EjChapter).chapter !== null;
const chapterNum = isChapter ? (content as EjChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ej"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Easy Journey to Other Planets
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<EjSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EjNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EjNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EjSidebar({
items,
currentSlug,
}: {
items: EjSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: EjSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ej/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function EjNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: EjSidebarItem | null;
next: EjSidebarItem | null;
sidebarItems: EjSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ej/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ej/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Easy Journey to Other Planets"
/>
{prev ? (
<Link
href={`/books/ej/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ej/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getEjMeta, getEjIntroSegments } from "@/lib/ej";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ej");
}
export default async function EjPage() {
const meta = await getEjMeta();
if (!meta) notFound();
const introSegments = await getEjIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ej/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ej/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getEkContent,
getEkSidebarItems,
type EkChapter,
type EkSidebarItem,
} from "@/lib/ek";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ek", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ek"], quote: hlQuote });
}
return ogForChapter("ek", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function EkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getEkContent(slug);
if (!content) notFound();
const sidebarItems = await getEkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as EkChapter).chapter === "number" &&
(content as EkChapter).chapter !== null;
const chapterNum = isChapter ? (content as EkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ek"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Elevation to Kṛṣṇa Consciousness
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<EkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EkSidebar({
items,
currentSlug,
}: {
items: EkSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: EkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ek/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function EkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: EkSidebarItem | null;
next: EkSidebarItem | null;
sidebarItems: EkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ek/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ek/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Elevation to Kṛṣṇa Consciousness"
/>
{prev ? (
<Link
href={`/books/ek/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ek/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getEkMeta, getEkIntroSegments } from "@/lib/ek";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ek");
}
export default async function EkPage() {
const meta = await getEkMeta();
if (!meta) notFound();
const introSegments = await getEkIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ek/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ek/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const TOOLTIP_TEXT =
"This is the number of extracted verse entries from Śrīla Prabhupāda's original translations and purports. Some consecutive verses are merged into a single entry (e.g. Texts 16–18), so the actual verse count in the original manuscript may differ.";
export function ExtractedVerseCount({
count,
label = "verses",
className = "",
}: {
count: number;
label?: string;
className?: string;
}) {
return (
<span className={`inline-flex items-center gap-1 ${className}`}>
<span>
{count.toLocaleString()} {label}
</span>
<Tooltip>
<TooltipTrigger
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="inline-flex items-center justify-center opacity-50 hover:opacity-80 transition-opacity"
aria-label="About verse count"
>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-pretty">
{TOOLTIP_TEXT}
</TooltipContent>
</Tooltip>
</span>
);
}
+357
View File
@@ -0,0 +1,357 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getGgContent,
getGgSidebarItems,
type GgChapter,
type GgSidebarItem,
} from "@/lib/gg";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("gg", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["gg"], quote: hlQuote });
}
return ogForChapter("gg", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function GgChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getGgContent(slug);
if (!content) notFound();
const sidebarItems = await getGgSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as GgChapter).chapter === "number" &&
(content as GgChapter).chapter !== null;
const chapterNum = isChapter ? (content as GgChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/gg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Gītār Gān
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<GgSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<GgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<GgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function GgSidebar({
items,
currentSlug,
}: {
items: GgSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: GgSidebarItem[] = [];
const chapterItems: GgSidebarItem[] = [];
const backMatterItems: GgSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatterItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: GgSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/gg/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function GgNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: GgSidebarItem | null;
next: GgSidebarItem | null;
sidebarItems: GgSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: GgSidebarItem[] = [];
const chapterItems: GgSidebarItem[] = [];
const backMatterItems: GgSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Gītār Gān"
/>
{prev ? (
<Link
href={`/books/gg/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/gg/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getGgMeta, getGgIntroSegments } from "@/lib/gg";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("gg");
}
export default async function GgPage() {
const meta = await getGgMeta();
if (!meta) notFound();
const introSegments = await getGgIntroSegments();
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/gg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getHkhContent,
getHkhSidebarItems,
type HkhChapter,
type HkhSidebarItem,
} from "@/lib/hkh";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("hkh", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["hkh"], quote: hlQuote });
}
return ogForChapter("hkh", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function HkhChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getHkhContent(slug);
if (!content) notFound();
const sidebarItems = await getHkhSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as HkhChapter).chapter === "number" &&
(content as HkhChapter).chapter !== null;
const chapterNum = isChapter ? (content as HkhChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/hkh"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Kṛṣṇa Consciousness Handbook
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<HkhSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<HkhNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<HkhNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function HkhSidebar({
items,
currentSlug,
}: {
items: HkhSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: HkhSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/hkh/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function HkhNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: HkhSidebarItem | null;
next: HkhSidebarItem | null;
sidebarItems: HkhSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/hkh/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/hkh/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Kṛṣṇa Consciousness Handbook"
/>
{prev ? (
<Link
href={`/books/hkh/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/hkh/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getHkhMeta, getHkhIntroSegments } from "@/lib/hkh";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("hkh");
}
export default async function HkhPage() {
const meta = await getHkhMeta();
if (!meta) notFound();
const introSegments = await getHkhIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/hkh/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/hkh/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+462
View File
@@ -0,0 +1,462 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getIsoContent,
getIsoSidebarItems,
type IsoVerse,
type IsoIntroSegment,
type IsoSidebarItem,
} from "@/lib/iso";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
function isVerse(content: IsoVerse | IsoIntroSegment): content is IsoVerse {
return "mantra" in content && "devanagari" in content;
}
function isIntroSegment(
content: IsoVerse | IsoIntroSegment,
): content is IsoIntroSegment {
return "paragraphs" in content && "order" in content;
}
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ mantra: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { mantra: slug } = await params;
const sp = await searchParams;
const content = await getIsoContent(slug);
const isVerse_ = content && "mantra" in content && "devanagari" in content;
const paragraphs = content
? isVerse_
? [
(content as unknown as IsoVerse).translation,
...(content as unknown as IsoVerse).purport,
]
: (content as unknown as IsoIntroSegment).paragraphs
: undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrī Īśopaniṣad",
detail: `Mantra ${slug}`,
quote,
});
}
const title = isVerse_
? `${(content as unknown as IsoVerse).reference}`
: content && "title" in content
? (content as IsoIntroSegment).title
: `Mantra ${slug}`;
return ogForChapter("iso", title);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function IsoMantraPage({
params,
}: {
params: Promise<{ mantra: string }>;
}) {
const { mantra: slug } = await params;
const content = await getIsoContent(slug);
if (!content) notFound();
const sidebarItems = await getIsoSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title for the header
const verse = isVerse(content) ? content : null;
const intro = isIntroSegment(content) ? content : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/iso"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Īśopaniṣad
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{verse && (
<>
<span className="text-primary">{verse.reference}: </span>
{verse.bookTitle}
</>
)}
{intro && intro.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<IsoSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<IsoNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
{verse && <VerseContent verse={verse} />}
{intro && <IntroContent intro={intro} />}
{/* Bottom navigation */}
<IsoNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
// ---------------------------------------------------------------------------
function VerseContent({ verse }: { verse: IsoVerse }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
{/* Devanagari */}
<section>
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
{verse.devanagari}
</div>
</section>
{/* Transliteration */}
<section>
<div className="text-base text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
{verse.transliteration}
</div>
</section>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Synonyms
</h2>
<div className="text-base text-foreground leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
{i > 0 && "; "}
<span className="font-medium text-foreground/90">
{s.word}
</span>
{"—"}
{s.meanings.join(", ")}
</span>
))}
</div>
</section>
)}
{/* Translation */}
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Translation
</h2>
<div
className="text-base text-foreground font-medium leading-relaxed"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</section>
{/* Purport */}
{verse.purport.length > 0 && (
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Purport
</h2>
<div className="space-y-4">
{verse.purport.map((p, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: p }}
/>
))}
</div>
</section>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Intro segment content (paragraphs)
// ---------------------------------------------------------------------------
function IntroContent({ intro }: { intro: IsoIntroSegment }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{intro.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function IsoSidebar({
items,
currentSlug,
}: {
items: IsoSidebarItem[];
currentSlug: string;
}) {
// Front matter items (front-pages + introduction)
const frontMatterItems = items.filter(
(item) =>
item.slug === "intro-front-pages" || item.slug === "intro-introduction",
);
// Mantras = invocation + 1-18
const mantraItems = items.filter((item) => item.type === "verse");
// about-the-author comes last
const aboutAuthorItem = items.find(
(item) => item.slug === "intro-about-the-author",
);
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Mantras */}
{mantraItems.length > 0 && (
<>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Mantras
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{mantraItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* About the Author */}
{aboutAuthorItem && (
<nav className="flex flex-col gap-0.5">
<SidebarLink
item={aboutAuthorItem}
isActive={aboutAuthorItem.slug === currentSlug}
/>
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: IsoSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/iso/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function IsoNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: IsoSidebarItem | null;
next: IsoSidebarItem | null;
sidebarItems: IsoSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
// Front matter (front-pages + introduction)
const frontMatterItems = sidebarItems.filter(
(i) => i.slug === "intro-front-pages" || i.slug === "intro-introduction",
);
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/iso/${i.slug}`,
type: i.type,
})),
});
}
// Mantras (invocation + 1-18)
const mantraItems = sidebarItems.filter((i) => i.type === "verse");
if (mantraItems.length > 0) {
mobileNavGroups.push({
title: "Mantras",
items: mantraItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/iso/${i.slug}`,
type: i.type,
})),
});
}
// Back matter (about-the-author)
const backMatterItems = sidebarItems.filter(
(i) => i.slug === "intro-about-the-author",
);
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/iso/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Śrī Īśopaniṣad"
/>
{prev ? (
<Link
href={`/books/iso/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "verse" && prev.mantra != null && prev.mantra > 0
? `Mantra ${prev.mantra}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/iso/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "verse" && next.mantra != null && next.mantra > 0
? `Mantra ${next.mantra}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIsoMeta, getIsoIntroSegments } from "@/lib/iso";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("iso");
}
export default async function IsoPage() {
const meta = await getIsoMeta();
if (!meta) notFound();
const introSegments = await getIsoIntroSegments();
const frontPages = introSegments.find((s) => s.slug === "front-pages");
const introduction = introSegments.find((s) => s.slug === "introduction");
const aboutAuthor = introSegments.find((s) => s.slug === "about-the-author");
// Invocation is mantra 0, mantras are 1-18
const invocation = meta.mantras.find((m) => m.number === 0);
const numberedMantras = meta.mantras.filter((m) => m.number > 0);
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{(frontPages || introduction) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontPages && (
<li>
<Link
href="/books/iso/intro-front-pages"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{frontPages.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{introduction && (
<li>
<Link
href="/books/iso/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{introduction.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Mantras */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Mantras
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{/* Invocation */}
{invocation && (
<li>
<Link
href="/books/iso/invocation"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Invocation
</span>
<p className="font-medium text-foreground">
{invocation.title}
</p>
</div>
</Link>
</li>
)}
{/* Mantras 1-18 */}
{numberedMantras.map((m) => (
<li key={m.number}>
<Link
href={`/books/iso/${m.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Mantra {m.number}
</span>
<p className="font-medium text-foreground">{m.title}</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* About the Author */}
{aboutAuthor && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
About the Author
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/iso/intro-about-the-author"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{aboutAuthor.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getProseChapter, getProseChapters } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ volume: string; slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { volume, slug } = await params;
const sp = await searchParams;
const vol = parseInt(volume);
const chapter = !isNaN(vol) ? await getProseChapter("kb", vol, slug) : null;
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({
title: BOOK_NAMES["kb"],
detail: chapter ? `Volume ${vol} — ${chapter.title}` : `Volume ${vol}`,
quote: hlQuote,
});
}
return ogForChapter(
"kb",
chapter ? `Volume ${vol} — ${chapter.title}` : `Volume ${vol}`,
);
}
export default async function KbChapterPage({
params,
}: {
params: Promise<{ volume: string; slug: string }>;
}) {
const { volume, slug } = await params;
const vol = parseInt(volume);
if (isNaN(vol) || vol < 1 || vol > 2) notFound();
const chapter = await getProseChapter("kb", vol, slug);
if (!chapter) notFound();
// Fetch all chapters/segments in this volume for sidebar + prev/next
const allChapters = await getProseChapters("kb", vol);
const currentIdx = allChapters.findIndex((ch) => ch.slug === slug);
const prev = currentIdx > 0 ? allChapters[currentIdx - 1] : null;
const next =
currentIdx < allChapters.length - 1 ? allChapters[currentIdx + 1] : null;
const chapterLabel =
chapter.type === "chapter" && chapter.chapter != null
? `Chapter ${chapter.chapter}`
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href={`/books/kb/${vol}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Volume {vol}
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterLabel && (
<span className="text-primary">{chapterLabel}: </span>
)}
{chapter.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar — chapter index */}
<ChapterSidebar
chapters={allChapters}
currentSlug={slug}
volume={vol}
/>
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<ChapterNav
prev={prev}
next={next}
volume={vol}
chapters={allChapters}
currentSlug={slug}
/>
{/* Chapter content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{chapter.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<ChapterNav
prev={prev}
next={next}
volume={vol}
chapters={allChapters}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar — chapter index
// ---------------------------------------------------------------------------
type SidebarChapter = {
slug: string;
title: string;
type: string;
chapter: number | null;
};
function ChapterSidebar({
chapters,
currentSlug,
volume,
}: {
chapters: SidebarChapter[];
currentSlug: string;
volume: number;
}) {
return (
<aside className="hidden lg:block w-56 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">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapters.map((ch) => {
const isActive = ch.slug === currentSlug;
const label =
ch.type === "chapter" && ch.chapter != null
? `${ch.chapter}. ${ch.title}`
: ch.title;
return (
<Link
key={ch.slug}
href={`/books/kb/${volume}/${ch.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
})}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
type NavChapter = {
slug: string;
title: string;
type: string;
chapter: number | null;
};
function ChapterNav({
prev,
next,
volume,
chapters,
currentSlug,
}: {
prev: NavChapter | null;
next: NavChapter | null;
volume: number;
chapters: SidebarChapter[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = chapters.filter((i) => i.type !== "chapter");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.title,
href: `/books/kb/${volume}/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = chapters.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.chapter != null ? `${i.chapter}. ${i.title}` : i.title,
href: `/books/kb/${volume}/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle={`Kṛṣṇa Book — Volume ${volume}`}
/>
{prev ? (
<Link
href={`/books/kb/${volume}/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.title}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/kb/${volume}/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.title}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta, getProseChapters } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export async function generateMetadata({
params,
}: {
params: Promise<{ volume: string }>;
}): Promise<Metadata> {
const { volume } = await params;
const title = BOOK_NAMES["kb"] || "KB";
return buildOgMeta({ title, detail: `Volume ${volume}` });
}
export default async function KbVolumePage({
params,
}: {
params: Promise<{ volume: string }>;
}) {
const { volume } = await params;
const vol = parseInt(volume);
if (isNaN(vol) || vol < 1 || vol > 2) notFound();
const meta = await getBookMeta("kb");
if (!meta) notFound();
const title = meta.title as string;
const [introSegments, chapters] = await Promise.all([
getProseChapters("kb", vol, "intro-segment"),
getProseChapters("kb", vol, "chapter"),
]);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/kb"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {title}
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Volume {vol}
</h1>
<p className="mt-1 text-muted-foreground">{chapters.length} chapters</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/kb/${vol}/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{chapters.map((ch) => (
<li key={ch.slug}>
<Link
href={`/books/kb/${vol}/${ch.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.chapter}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
type VolumeInfo = {
number: number;
totalChapters: number;
};
export function generateMetadata(): Metadata {
return ogForBook("kb");
}
export default async function KbPage() {
const meta = await getBookMeta("kb");
if (!meta) notFound();
const title = meta.title as string;
const author = meta.author as string;
const description = meta.description as string | undefined;
const volumes = meta.volumes as VolumeInfo[];
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">{title}</h1>
<p className="mt-1 text-muted-foreground">{author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
{description && (
<p className="text-muted-foreground mb-6">{description}</p>
)}
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{volumes.map((vol) => (
<li key={vol.number}>
<Link
href={`/books/kb/${vol.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
Volume {vol.number}
</Badge>
<p className="font-medium text-foreground">
{vol.totalChapters} chapters
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+361
View File
@@ -0,0 +1,361 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getLclContent,
getLclSidebarItems,
type LclChapter,
type LclSidebarItem,
} from "@/lib/lcl";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("lcl", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["lcl"], quote: hlQuote });
}
return ogForChapter("lcl", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function LclChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getLclContent(slug);
if (!content) notFound();
const sidebarItems = await getLclSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as LclChapter).chapter === "number" &&
(content as LclChapter).chapter !== null;
const chapterNum = isChapter ? (content as LclChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/lcl"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Life Comes from Life
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<LclSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<LclNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<LclNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function LclSidebar({
items,
currentSlug,
}: {
items: LclSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters (about-the-author)
const frontMatterItems: LclSidebarItem[] = [];
const chapterItems: LclSidebarItem[] = [];
const backMatterItems: LclSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Back Matter (About the Author) */}
{backMatterItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: LclSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/lcl/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function LclNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: LclSidebarItem | null;
next: LclSidebarItem | null;
sidebarItems: LclSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: LclSidebarItem[] = [];
const chapterItems: LclSidebarItem[] = [];
const backMatterItems: LclSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Life Comes from Life"
/>
{prev ? (
<Link
href={`/books/lcl/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/lcl/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLclMeta, getLclIntroSegments } from "@/lib/lcl";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("lcl");
}
export default async function LclPage() {
const meta = await getLclMeta();
if (!meta) notFound();
const introSegments = await getLclIntroSegments();
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/lcl/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter (About the Author) */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getLobContent,
getLobSidebarItems,
type LobChapter,
type LobSidebarItem,
} from "@/lib/lob";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("lob", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["lob"], quote: hlQuote });
}
return ogForChapter("lob", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function LobChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getLobContent(slug);
if (!content) notFound();
const sidebarItems = await getLobSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as LobChapter).chapter === "number" &&
(content as LobChapter).chapter !== null;
const chapterNum = isChapter ? (content as LobChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/lob"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Light of the Bh&#257;gavata
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Text {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<LobSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<LobNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) =>
paragraph.startsWith("<b>") ? (
<div
key={i}
className="text-base text-foreground leading-relaxed font-medium bg-muted/30 rounded-lg p-4 border border-border/50"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
) : (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
),
)}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<LobNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function LobSidebar({
items,
currentSlug,
}: {
items: LobSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Texts: chapter items
// (LoB has no back matter)
const frontMatterItems: LobSidebarItem[] = [];
const textItems: LobSidebarItem[] = [];
for (const item of items) {
if (item.type === "chapter") {
textItems.push(item);
} else {
frontMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Texts */}
{textItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Texts
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{textItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: LobSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Format: "N. Text N"
label = `${item.chapter}. Text ${item.chapter}`;
} else {
label = item.label;
}
return (
<Link
href={`/books/lob/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function LobNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: LobSidebarItem | null;
next: LobSidebarItem | null;
sidebarItems: LobSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: LobSidebarItem[] = [];
const textItems: LobSidebarItem[] = [];
for (const item of sidebarItems) {
if (item.type === "chapter") {
textItems.push(item);
} else {
frontMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lob/${i.slug}`,
type: i.type,
})),
});
}
if (textItems.length > 0) {
mobileNavGroups.push({
title: "Texts",
items: textItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lob/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Light of the Bhāgavata"
/>
{prev ? (
<Link
href={`/books/lob/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Text ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/lob/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Text ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLobMeta, getLobIntroSegments } from "@/lib/lob";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("lob");
}
export default async function LobPage() {
const meta = await getLobMeta();
if (!meta) notFound();
const introSegments = await getLobIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lob/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Texts */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Texts
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/lob/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Text {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getMgContent,
getMgSidebarItems,
type MgChapter,
type MgSidebarItem,
} from "@/lib/mg";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("mg", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["mg"], quote: hlQuote });
}
return ogForChapter("mg", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function MgChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getMgContent(slug);
if (!content) notFound();
const sidebarItems = await getMgSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as MgChapter).chapter === "number" &&
(content as MgChapter).chapter !== null;
const chapterNum = isChapter ? (content as MgChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Kṛṣṇa Consciousness: The Matchless Gift
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<MgSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<MgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<MgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function MgSidebar({
items,
currentSlug,
}: {
items: MgSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: MgSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/mg/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function MgNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: MgSidebarItem | null;
next: MgSidebarItem | null;
sidebarItems: MgSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mg/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mg/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Kṛṣṇa Consciousness: The Matchless Gift"
/>
{prev ? (
<Link
href={`/books/mg/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/mg/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMgMeta, getMgIntroSegments } from "@/lib/mg";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("mg");
}
export default async function MgPage() {
const meta = await getMgMeta();
if (!meta) notFound();
const introSegments = await getMgIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/mg/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,215 @@
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import type { Metadata } from "next";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getVerseWithFallback(parseInt(chapter), parseInt(verse));
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Mukunda-mālā-stotra",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("mm", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
type MmMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("mm");
if (meta) return meta as unknown as MmMeta;
const introPath = path.join(MM_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("mm", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("mm", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `mm-${chapter}-${verse}`,
book: "mm",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const mmMeta = await getMetaWithFallback();
if (!mmMeta) notFound();
const chapterInfo = mmMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/mm/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mm"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Mukunda-mālā-stotra
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="mm"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/mm/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({ title: BOOK_NAMES["mm"], detail: `Chapter ${chapter}` });
}
export default async function MmChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(MM_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mm"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Mukunda-mālā-stotra
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("mm", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/mm/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/mm/${chapter}/${verseIndex[0].verse}`);
}
+163
View File
@@ -0,0 +1,163 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
type MmMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type MmIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
async function getMetaWithFallback(): Promise<MmMeta | null> {
const meta = await getBookMeta("mm");
if (meta) return meta as unknown as MmMeta;
const introPath = path.join(MM_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): MmIntroSegment[] {
const introDir = path.join(MM_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: MmIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as MmIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export function generateMetadata(): Metadata {
return ogForBook("mm");
}
export default async function MmPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introduction && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/mm/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mm/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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 sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getMogContent,
getMogSidebarItems,
type MogChapter,
type MogSidebarItem,
} from "@/lib/mog";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("mog", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["mog"], quote: hlQuote });
}
return ogForChapter("mog", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function MogChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getMogContent(slug);
if (!content) notFound();
const sidebarItems = await getMogSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as MogChapter).chapter === "number" &&
(content as MogChapter).chapter !== null;
const chapterNum = isChapter ? (content as MogChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Message of Godhead
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<MogSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<MogNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<MogNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function MogSidebar({
items,
currentSlug,
}: {
items: MogSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: MogSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/mog/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function MogNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: MogSidebarItem | null;
next: MogSidebarItem | null;
sidebarItems: MogSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mog/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mog/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Message of Godhead"
/>
{prev ? (
<Link
href={`/books/mog/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/mog/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMogMeta, getMogIntroSegments } from "@/lib/mog";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("mog");
}
export default async function MogPage() {
const meta = await getMogMeta();
if (!meta) notFound();
const introSegments = await getMogIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/mog/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mog/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,220 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
type NbsMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("nbs");
if (meta) return meta as unknown as NbsMeta;
const introPath = path.join(NBS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("nbs", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("nbs", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `nbs-${chapter}-${verse}`,
book: "nbs",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"nbs",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Nārada-bhakti-sūtra",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("nbs", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const nbsMeta = await getMetaWithFallback();
if (!nbsMeta) notFound();
const chapterInfo = nbsMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/nbs/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/nbs"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Nārada-bhakti-sūtra
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="nbs"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/nbs/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({
title: BOOK_NAMES["nbs"],
detail: `Chapter ${chapter}`,
});
}
export default async function NbsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(NBS_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/nbs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Nārada-bhakti-sūtra
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("nbs", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/nbs/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/nbs/${chapter}/${verseIndex[0].verse}`);
}
+163
View File
@@ -0,0 +1,163 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
export function generateMetadata(): Metadata {
return ogForBook("nbs");
}
type NbsMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type NbsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
async function getMetaWithFallback(): Promise<NbsMeta | null> {
const meta = await getBookMeta("nbs");
if (meta) return meta as unknown as NbsMeta;
const introPath = path.join(NBS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): NbsIntroSegment[] {
const introDir = path.join(NBS_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: NbsIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as NbsIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export default async function NbsPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introduction && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/nbs/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/nbs/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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 sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+326
View File
@@ -0,0 +1,326 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getNodContent,
getNodSidebarItems,
type NodChapter,
type NodSidebarItem,
} from "@/lib/nod";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("nod", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["nod"], quote: hlQuote });
}
return ogForChapter("nod", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function NodChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getNodContent(slug);
if (!content) notFound();
const sidebarItems = await getNodSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as NodChapter).chapter === "number" &&
(content as NodChapter).chapter !== null;
const chapterNum = isChapter ? (content as NodChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/nod"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Nectar of Devotion
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<NodSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<NodNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<NodNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function NodSidebar({
items,
currentSlug,
}: {
items: NodSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
const conclusionItems = items.filter((item) => item.type === "conclusion");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Concluding Words */}
{conclusionItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{conclusionItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: NodSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/nod/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function NodNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: NodSidebarItem | null;
next: NodSidebarItem | null;
sidebarItems: NodSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/nod/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
const conclusionItems = sidebarItems.filter((i) => i.type === "conclusion");
if (chapterItems.length > 0 || conclusionItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: [
...chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/nod/${i.slug}`,
type: i.type,
})),
...conclusionItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/nod/${i.slug}`,
type: i.type,
})),
],
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Nectar of Devotion"
/>
{prev ? (
<Link
href={`/books/nod/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/nod/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getNodMeta, getNodIntroSegments } from "@/lib/nod";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("nod");
}
const PART_LABELS: Record<number, string> = {
1: "Part One",
2: "Part Two",
3: "Part Three",
4: "Part Four",
};
export default async function NodPage() {
const meta = await getNodMeta();
if (!meta) notFound();
const introSegments = await getNodIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/nod/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts & Chapters */}
{meta.parts.map((part) => {
const partChapters = part.chapters
.map((num) => meta.chapters.find((ch) => ch.number === num))
.filter(Boolean) as { number: number; title: string }[];
return (
<div key={part.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{PART_LABELS[part.number] ?? `Part ${part.number}`}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{partChapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/nod/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
})}
{/* Concluding Words */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Conclusion
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/nod/concluding-words"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
Concluding Words
</p>
</div>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+451
View File
@@ -0,0 +1,451 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getNoiContent,
getNoiSidebarItems,
type NoiVerse,
type NoiIntroSegment,
type NoiSidebarItem,
} from "@/lib/noi";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
function isVerse(content: NoiVerse | NoiIntroSegment): content is NoiVerse {
return "text" in content && "devanagari" in content;
}
function isIntroSegment(
content: NoiVerse | NoiIntroSegment,
): content is NoiIntroSegment {
return "paragraphs" in content && "order" in content;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ text: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { text: slug } = await params;
const sp = await searchParams;
const content = await getNoiContent(slug);
let paragraphs: string[] | undefined;
if (content) {
if ("devanagari" in content) {
// It's a verse
paragraphs = [content.translation, ...content.purport];
} else if ("paragraphs" in content) {
// It's an intro segment
paragraphs = content.paragraphs;
}
}
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "The Nectar of Instruction",
detail: `Text ${slug}`,
quote,
});
}
return ogForChapter("noi", `Text ${slug}`);
}
export default async function NoiTextPage({
params,
}: {
params: Promise<{ text: string }>;
}) {
const { text: slug } = await params;
const content = await getNoiContent(slug);
if (!content) notFound();
const sidebarItems = await getNoiSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title for the header
const verse = isVerse(content) ? content : null;
const intro = isIntroSegment(content) ? content : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/noi"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Nectar of Instruction
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{verse && (
<>
<span className="text-primary">Text {verse.text}: </span>
{verse.title}
</>
)}
{intro && intro.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<NoiSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<NoiNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
{verse && <VerseContent verse={verse} />}
{intro && <IntroContent intro={intro} />}
{/* Bottom navigation */}
<NoiNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
// ---------------------------------------------------------------------------
function VerseContent({ verse }: { verse: NoiVerse }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
{/* Devanagari */}
<section>
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
{verse.devanagari}
</div>
</section>
{/* Transliteration */}
<section>
<div className="text-base text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
{verse.transliteration}
</div>
</section>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Synonyms
</h2>
<div className="text-base text-foreground leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
{i > 0 && "; "}
<span className="font-medium text-foreground/90">
{s.word}
</span>
{"—"}
{s.meanings.join(", ")}
</span>
))}
</div>
</section>
)}
{/* Translation */}
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Translation
</h2>
<div
className="text-base text-foreground font-medium leading-relaxed"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</section>
{/* Purport */}
{verse.purport.length > 0 && (
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Purport
</h2>
<div className="space-y-4">
{verse.purport.map((p, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: p }}
/>
))}
</div>
</section>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Intro segment content (paragraphs)
// ---------------------------------------------------------------------------
function IntroContent({ intro }: { intro: NoiIntroSegment }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{intro.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function NoiSidebar({
items,
currentSlug,
}: {
items: NoiSidebarItem[];
currentSlug: string;
}) {
// Front matter items (front-pages + preface)
const frontMatterItems = items.filter(
(item) =>
item.slug === "intro-front-pages" || item.slug === "intro-preface",
);
// Texts = 1-11
const textItems = items.filter((item) => item.type === "verse");
// Appendixes comes last
const appendixesItem = items.find((item) => item.slug === "intro-appendixes");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Texts */}
{textItems.length > 0 && (
<>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Texts
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{textItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Appendixes */}
{appendixesItem && (
<nav className="flex flex-col gap-0.5">
<SidebarLink
item={appendixesItem}
isActive={appendixesItem.slug === currentSlug}
/>
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: NoiSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/noi/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function NoiNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: NoiSidebarItem | null;
next: NoiSidebarItem | null;
sidebarItems: NoiSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
// Front matter (front-pages + preface)
const frontMatterItems = sidebarItems.filter(
(i) => i.slug === "intro-front-pages" || i.slug === "intro-preface",
);
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/noi/${i.slug}`,
type: i.type,
})),
});
}
// Texts (1-11)
const textItems = sidebarItems.filter((i) => i.type === "verse");
if (textItems.length > 0) {
mobileNavGroups.push({
title: "Texts",
items: textItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/noi/${i.slug}`,
type: i.type,
})),
});
}
// Back matter (appendixes)
const backMatterItems = sidebarItems.filter(
(i) => i.slug === "intro-appendixes",
);
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/noi/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Nectar of Instruction"
/>
{prev ? (
<Link
href={`/books/noi/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "verse" && prev.text != null
? `Text ${prev.text}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/noi/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "verse" && next.text != null
? `Text ${next.text}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getNoiMeta, getNoiIntroSegments } from "@/lib/noi";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("noi");
}
export default async function NoiPage() {
const meta = await getNoiMeta();
if (!meta) notFound();
const introSegments = await getNoiIntroSegments();
const frontPages = introSegments.find((s) => s.slug === "front-pages");
const preface = introSegments.find((s) => s.slug === "preface");
const appendixes = introSegments.find((s) => s.slug === "appendixes");
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">
{meta.title}
</h1>
<p className="mt-1 italic text-muted-foreground">{meta.subtitle}</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{(frontPages || preface) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontPages && (
<li>
<Link
href="/books/noi/intro-front-pages"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{frontPages.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{preface && (
<li>
<Link
href="/books/noi/intro-preface"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{preface.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Texts */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Texts
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.texts.map((t) => (
<li key={t.number}>
<Link
href={`/books/noi/${t.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Text {t.number}
</span>
<p className="font-medium text-foreground">{t.title}</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Appendixes */}
{appendixes && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Appendixes
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/noi/intro-appendixes"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{appendixes.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getOchkmContent,
getOchkmSidebarItems,
type OchkmChapter,
type OchkmSidebarItem,
} from "@/lib/ochkm";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ochkm", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ochkm"], quote: hlQuote });
}
return ogForChapter("ochkm", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function OchkmChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getOchkmContent(slug);
if (!content) notFound();
const sidebarItems = await getOchkmSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as OchkmChapter).chapter === "number" &&
(content as OchkmChapter).chapter !== null;
const chapterNum = isChapter ? (content as OchkmChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ochkm"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; On Chanting the Hare Kṛṣṇa Mantra
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<OchkmSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<OchkmNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<OchkmNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function OchkmSidebar({
items,
currentSlug,
}: {
items: OchkmSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: OchkmSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ochkm/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function OchkmNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: OchkmSidebarItem | null;
next: OchkmSidebarItem | null;
sidebarItems: OchkmSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ochkm/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ochkm/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="On Chanting the Hare Kṛṣṇa Mantra"
/>
{prev ? (
<Link
href={`/books/ochkm/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ochkm/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getOchkmMeta, getOchkmIntroSegments } from "@/lib/ochkm";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ochkm");
}
export default async function OchkmPage() {
const meta = await getOchkmMeta();
if (!meta) notFound();
const introSegments = await getOchkmIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ochkm/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ochkm/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getOwkContent,
getOwkSidebarItems,
type OwkChapter,
type OwkSidebarItem,
} from "@/lib/owk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("owk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["owk"], quote: hlQuote });
}
return ogForChapter("owk", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function OwkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getOwkContent(slug);
if (!content) notFound();
const sidebarItems = await getOwkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as OwkChapter).chapter === "number" &&
(content as OwkChapter).chapter !== null;
const chapterNum = isChapter ? (content as OwkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/owk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; On the Way to Kṛṣṇa
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<OwkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<OwkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<OwkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function OwkSidebar({
items,
currentSlug,
}: {
items: OwkSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: OwkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/owk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function OwkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: OwkSidebarItem | null;
next: OwkSidebarItem | null;
sidebarItems: OwkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/owk/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/owk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="On the Way to Kṛṣṇa"
/>
{prev ? (
<Link
href={`/books/owk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/owk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getOwkMeta, getOwkIntroSegments } from "@/lib/owk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("owk");
}
export default async function OwkPage() {
const meta = await getOwkMeta();
if (!meta) notFound();
const introSegments = await getOwkIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/owk/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/owk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { getAllBooks, type BookMeta } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BooksGrid, type BookInfo } from "./books-grid";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Books",
detail: "Books by Śrīla Prabhupāda",
quote:
"Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, and 30+ other books with Devanagari, transliteration, synonyms, translations & purports.",
});
}
type ChapterEntry = { totalVerses: number };
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
type LilaEntry = { totalChapters: number; chapters: ChapterEntry[] };
interface ChapterBookMeta extends BookMeta {
totalChapters: number;
chapters: ChapterEntry[];
}
interface SbMeta extends BookMeta {
totalCantos: number;
cantos: CantoEntry[];
}
interface CcMeta extends BookMeta {
totalLilas: number;
lilas: LilaEntry[];
}
function buildBookList(allBooks: BookMeta[]): BookInfo[] {
const books: BookInfo[] = [];
for (const data of allBooks) {
const slug = data.book;
const title = data.title as string;
const author = data.author as string;
if (slug === "bg") {
const bg = data as ChapterBookMeta;
const totalVerses = bg.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
books.push({
slug,
title,
author,
description: `${bg.totalChapters} chapters`,
contentDescription:
"The essence of Vedic knowledge — Lord Kṛṣṇa's teachings to Arjuna on the battlefield of Kurukṣetra.",
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`,
contentDescription:
(data as { description?: string }).description ||
"The original 1962 Indian edition of the First Canto.",
totalVerses,
href: "/books/sb1962",
});
} else if (slug === "sb") {
const sb = data as SbMeta;
const totalChapters = sb.cantos.reduce(
(sum, c) => sum + c.totalChapters,
0,
);
const totalVerses = sb.cantos.reduce(
(sum, c) => sum + c.chapters.reduce((s, ch) => s + ch.totalVerses, 0),
0,
);
books.push({
slug,
title,
author,
description: `${sb.totalCantos} cantos, ${totalChapters} chapters`,
contentDescription:
"The ripened fruit of Vedic literature — the science of God in 18,000 verses.",
totalVerses,
href: "/books/sb",
});
} else if (slug === "cc") {
const cc = data as CcMeta;
const totalChapters = cc.lilas.reduce(
(sum, l) => sum + l.totalChapters,
0,
);
const totalVerses = cc.lilas.reduce(
(sum, l) => sum + l.chapters.reduce((s, ch) => s + ch.totalVerses, 0),
0,
);
books.push({
slug,
title,
author,
description: `${cc.totalLilas} līlās, ${totalChapters} chapters`,
contentDescription:
"The life and teachings of Lord Caitanya Mahāprabhu — the most complete theistic science.",
totalVerses,
href: "/books/cc",
});
} else {
// Generic handler for all other books — pulls description from metadata
const metaDesc = (data as { description?: string }).description || "";
const totalChapters = (data as { totalChapters?: number }).totalChapters;
const totalMantras = (data as { totalMantras?: number }).totalMantras;
const totalTexts = (data as { totalTexts?: number }).totalTexts;
const parts = (data as { parts?: unknown[] }).parts;
const volumes = (data as { volumes?: unknown[] }).volumes;
let badge = "";
if (totalMantras) badge = `${totalMantras} mantras`;
else if (totalTexts) badge = `${totalTexts} texts`;
else if (totalChapters && volumes)
badge = `${totalChapters} chapters · ${volumes.length} volumes`;
else if (totalChapters && parts)
badge = `${totalChapters} chapters · ${parts.length} parts`;
else if (totalChapters) badge = `${totalChapters} chapters`;
books.push({
slug,
title,
author,
description: badge,
contentDescription: metaDesc,
totalVerses: 0,
href: `/books/${slug}`,
});
}
}
return books;
}
export default async function BooksPage() {
const allBooks = await getAllBooks();
const books = buildBookList(allBooks);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Books</h1>
<p className="mt-1 text-muted-foreground">
Browse the books and teachings of Śrīla Prabhupāda
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<BooksGrid books={books} />
</main>
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPkContent,
getPkSidebarItems,
type PkChapter,
type PkSidebarItem,
} from "@/lib/pk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("pk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["pk"], quote: hlQuote });
}
return ogForChapter("pk", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPkContent(slug);
if (!content) notFound();
const sidebarItems = await getPkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as PkChapter).chapter;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/pk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Prayers of King Kulaśekhara
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Verse {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<PkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PkSidebar({
items,
currentSlug,
}: {
items: PkSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Chapters (Prayers) */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Prayers
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: PkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/pk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function PkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: PkSidebarItem | null;
next: PkSidebarItem | null;
sidebarItems: PkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Prayers",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Prayers of King Kulaśekhara"
/>
{prev ? (
<Link
href={`/books/pk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.chapter != null ? `Prayer ${prev.chapter}` : prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/pk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.chapter != null ? `Prayer ${next.chapter}` : next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPkMeta } from "@/lib/pk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("pk");
}
export default async function PkPage() {
const meta = await getPkMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters (Prayers) */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Prayers
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/pk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Verse {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPoyContent,
getPoySidebarItems,
type PoyChapter,
type PoySidebarItem,
} from "@/lib/poy";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("poy", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["poy"], quote: hlQuote });
}
return ogForChapter("poy", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PoyChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPoyContent(slug);
if (!content) notFound();
const sidebarItems = await getPoySidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as PoyChapter).chapter === "number" &&
(content as PoyChapter).chapter !== null;
const chapterNum = isChapter ? (content as PoyChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/poy"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Perfection of Yoga
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<PoySidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PoyNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PoyNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PoySidebar({
items,
currentSlug,
}: {
items: PoySidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: PoySidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/poy/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function PoyNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: PoySidebarItem | null;
next: PoySidebarItem | null;
sidebarItems: PoySidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/poy/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/poy/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Perfection of Yoga"
/>
{prev ? (
<Link
href={`/books/poy/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/poy/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPoyMeta, getPoyIntroSegments } from "@/lib/poy";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("poy");
}
export default async function PoyPage() {
const meta = await getPoyMeta();
if (!meta) notFound();
const introSegments = await getPoyIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/poy/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/poy/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+391
View File
@@ -0,0 +1,391 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPqpaContent,
getPqpaSidebarItems,
type PqpaChapter,
type PqpaSidebarItem,
} from "@/lib/pqpa";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("pqpa", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["pqpa"], quote: hlQuote });
}
return ogForChapter("pqpa", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PqpaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPqpaContent(slug);
if (!content) notFound();
const sidebarItems = await getPqpaSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as PqpaChapter).chapter === "number" &&
(content as PqpaChapter).chapter !== null;
const chapterNum = isChapter ? (content as PqpaChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/pqpa"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Perfect Questions Perfect Answers
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<PqpaSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PqpaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PqpaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PqpaSidebar({
items,
currentSlug,
}: {
items: PqpaSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Conclusion: conclusion items
// Back matter: intro-segment items after conclusion (about-the-author)
const frontMatterItems: PqpaSidebarItem[] = [];
const chapterItems: PqpaSidebarItem[] = [];
const conclusionItems: PqpaSidebarItem[] = [];
const backMatterItems: PqpaSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (item.type === "conclusion") {
pastChapters = true;
conclusionItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Concluding Words */}
{conclusionItems.length > 0 && (
<nav className="flex flex-col gap-0.5 mb-4">
{conclusionItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
{/* About the Author */}
{backMatterItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: PqpaSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/pqpa/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function PqpaNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: PqpaSidebarItem | null;
next: PqpaSidebarItem | null;
sidebarItems: PqpaSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: PqpaSidebarItem[] = [];
const chapterItems: PqpaSidebarItem[] = [];
const conclusionItems: PqpaSidebarItem[] = [];
const backMatterItems: PqpaSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (item.type === "conclusion") {
pastChapters = true;
conclusionItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pqpa/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: [
...chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pqpa/${i.slug}`,
type: i.type,
})),
...conclusionItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pqpa/${i.slug}`,
type: i.type,
})),
],
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pqpa/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Perfect Questions Perfect Answers"
/>
{prev ? (
<Link
href={`/books/pqpa/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/pqpa/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPqpaMeta, getPqpaIntroSegments } from "@/lib/pqpa";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("pqpa");
}
export default async function PqpaPage() {
const meta = await getPqpaMeta();
if (!meta) notFound();
const introSegments = await getPqpaIntroSegments();
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/pqpa/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/pqpa/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Concluding Words */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Conclusion
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/pqpa/concluding-words"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
Concluding Words
</p>
</div>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
{/* About the Author */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/pqpa/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getRvContent,
getRvSidebarItems,
type RvChapter,
type RvSidebarItem,
} from "@/lib/rv";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("rv", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["rv"], quote: hlQuote });
}
return ogForChapter("rv", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function RvChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getRvContent(slug);
if (!content) notFound();
const sidebarItems = await getRvSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as RvChapter).chapter === "number" &&
(content as RvChapter).chapter !== null;
const chapterNum = isChapter ? (content as RvChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/rv"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Rāja-vidyā: The King of Knowledge
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<RvSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<RvNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<RvNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function RvSidebar({
items,
currentSlug,
}: {
items: RvSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: RvSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/rv/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function RvNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: RvSidebarItem | null;
next: RvSidebarItem | null;
sidebarItems: RvSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/rv/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/rv/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Rāja-vidyā: The King of Knowledge"
/>
{prev ? (
<Link
href={`/books/rv/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/rv/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getRvMeta, getRvIntroSegments } from "@/lib/rv";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("rv");
}
export default async function RvPage() {
const meta = await getRvMeta();
if (!meta) notFound();
const introSegments = await getRvIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/rv/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/rv/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,135 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
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/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ canto: string; chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { canto, chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"sb",
canto,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrīmad-Bhāgavatam",
detail: `Canto ${canto}, Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("sb", {
division: canto,
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
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();
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${canto}.${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${canto}.${chapter}.${v.verse}`,
href: `/books/sb/${canto}/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<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="max-w-5xl 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}`}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
/>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
export async function generateMetadata({
params,
}: {
params: Promise<{ canto: string; chapter: string }>;
}): Promise<Metadata> {
const { canto, chapter } = await params;
return buildOgMeta({
title: "Śrīmad-Bhāgavatam",
detail: `Canto ${canto}, Chapter ${chapter}`,
});
}
export default async function SbChapterPage({
params,
}: {
params: Promise<{ canto: string; chapter: string }>;
}) {
const { canto, chapter } = await params;
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/sb/${canto}/${chapter}/${verseIndex[0].verse}`);
}
+98
View File
@@ -0,0 +1,98 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../../extracted-verse-count";
import { getBookMeta } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
export async function generateMetadata({
params,
}: {
params: Promise<{ canto: string }>;
}): Promise<Metadata> {
const { canto } = await params;
const title = BOOK_NAMES["sb"] || "Śrīmad-Bhāgavatam";
return buildOgMeta({ title, detail: `Canto ${canto}` });
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export default async function CantoPage({
params,
}: {
params: Promise<{ canto: string }>;
}) {
const { canto } = await params;
const cantoNum = parseInt(canto);
const meta = await getBookMeta("sb");
if (!meta) notFound();
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
const cantoInfo = cantos.find((c) => c.number === cantoNum);
if (!cantoInfo) notFound();
const totalVerses = cantoInfo.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/sb"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrīmad-Bhāgavatam
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Canto {cantoInfo.number}: {cantoInfo.title}
</h1>
<p className="mt-1 text-muted-foreground">
{cantoInfo.totalChapters} chapters ·{" "}
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{cantoInfo.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/sb/${cantoNum}/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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 sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ExtractedVerseCount } from "../extracted-verse-count";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { notFound } from "next/navigation";
export function generateMetadata(): Metadata {
return ogForBook("sb");
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export default async function SbPage() {
const meta = await getBookMeta("sb");
if (!meta) notFound();
const intro = meta as {
book: string;
title: string;
author: string;
totalCantos: number;
cantos: CantoInfo[];
};
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground"
>
&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-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{intro.cantos.map((canto) => {
const totalVerses = canto.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<li key={canto.number}>
<Link
href={`/books/sb/${canto.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
Canto {canto.number}
</Badge>
<p className="font-medium text-foreground">
{canto.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{canto.totalChapters} chapters ·{" "}
<ExtractedVerseCount
count={totalVerses}
label="extracted verses"
/>
</span>
</Link>
</li>
);
})}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
@@ -0,0 +1,132 @@
import type { Metadata } from "next";
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/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
type Sb1962Meta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"sb1962",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrīmad-Bhāgavatam (1962)",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("sb1962", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
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();
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/sb1962/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<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="max-w-5xl 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}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
/>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({
title: BOOK_NAMES["sb1962"],
detail: `Chapter ${chapter}`,
});
}
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}`);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
export function generateMetadata(): Metadata {
return ogForBook("sb1962");
}
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-5xl 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 flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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 sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getSsrContent,
getSsrSidebarItems,
type SsrChapter,
type SsrSidebarItem,
} from "@/lib/ssr";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ssr", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ssr"], quote: hlQuote });
}
return ogForChapter("ssr", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function SsrChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getSsrContent(slug);
if (!content) notFound();
const sidebarItems = await getSsrSidebarItems();
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
const next =
currentIdx < navigableItems.length - 1
? navigableItems[currentIdx + 1]
: null;
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
const isSubChapter =
"parentChapter" in content &&
typeof (content as SsrChapter).parentChapter === "number";
const parentChapterNum = isSubChapter
? (content as SsrChapter).parentChapter
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ssr"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Science of Self-Realization
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Chapter {parentChapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<SsrSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<SsrNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<SsrNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function SsrSidebar({
items,
currentSlug,
}: {
items: SsrSidebarItem[];
currentSlug: string;
}) {
// Split items by section
const frontMatter = items.filter(
(i) => i.type === "intro-segment" && i.slug !== "intro-about-the-author",
);
const parents = items.filter((i) => i.type === "parent");
const subChapters = items.filter((i) => i.type === "sub-chapter");
const backMatter = items.filter((i) => i.slug === "intro-about-the-author");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatter.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatter.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters — grouped by parent */}
{parents.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{parents.map((parent) => (
<div key={parent.slug}>
{/* Parent heading — not a link, just a bold label */}
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
{parent.label}
</div>
{/* Sub-chapters indented */}
{subChapters
.filter((sub) => sub.parentChapter === parent.parentChapter)
.map((sub) => {
const isActive = sub.slug === currentSlug;
return (
<Link
key={sub.slug}
href={`/books/ssr/${sub.slug}`}
className={cn(
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={sub.label}
>
{sub.label}
</Link>
);
})}
</div>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatter.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Back Matter
</h2>
<nav className="flex flex-col gap-0.5">
{backMatter.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: SsrSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ssr/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function SsrNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: SsrSidebarItem | null;
next: SsrSidebarItem | null;
sidebarItems: SsrSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatter = sidebarItems.filter(
(i) => i.type === "intro-segment" && i.slug !== "intro-about-the-author",
);
if (frontMatter.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatter.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ssr/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter(
(i) => i.type === "parent" || i.type === "sub-chapter",
);
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ssr/${i.slug}`,
type: i.type,
parentChapter: i.parentChapter,
isParent: i.type === "parent",
})),
});
}
const backMatter = sidebarItems.filter(
(i) => i.slug === "intro-about-the-author",
);
if (backMatter.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatter.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ssr/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Science of Self-Realization"
/>
{prev ? (
<Link
href={`/books/ssr/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ssr/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getSsrMeta, getSsrIntroSegments } from "@/lib/ssr";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ssr");
}
export default async function SsrPage() {
const meta = await getSsrMeta();
if (!meta) notFound();
const introSegments = await getSsrIntroSegments();
// Split intro segments into front matter (before chapters) and back matter (after)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ssr/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters — hierarchical parent → sub-chapter structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{ch.number}. {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/ssr/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
{/* Back Matter (About the Author) */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ssr/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getSvaContent,
getSvaSidebarItems,
getNavigableItems,
type SvaChapter,
type SvaSidebarItem,
} from "@/lib/sva";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("sva", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["sva"], quote: hlQuote });
}
return ogForChapter("sva", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function SvaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getSvaContent(slug);
if (!content) notFound();
const sidebarItems = await getSvaSidebarItems();
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
const navigableItems = getNavigableItems(sidebarItems);
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
const next =
currentIdx < navigableItems.length - 1
? navigableItems[currentIdx + 1]
: null;
// Determine if this is a sub-chapter (has parentChapter)
const isSubChapter =
"parentChapter" in content &&
typeof (content as SvaChapter).parentChapter === "number";
const parentChapterNum = isSubChapter
? (content as SvaChapter).parentChapter
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/sva"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Songs of the Vaiṣṇava Ācāryas
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Part {parentChapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<SvaSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<SvaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<SvaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function SvaSidebar({
items,
currentSlug,
}: {
items: SvaSidebarItem[];
currentSlug: string;
}) {
// Split items by section
const frontMatter = items.filter((i) => i.type === "intro-segment");
const parents = items.filter((i) => i.type === "parent");
const subChapters = items.filter((i) => i.type === "sub-chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatter.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatter.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Parts — grouped by parent */}
{parents.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Songs
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{parents.map((parent) => (
<div key={parent.slug}>
{/* Parent heading — not a link, just a bold label */}
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
{parent.label}
</div>
{/* Sub-chapters (songs) indented */}
{subChapters
.filter((sub) => sub.parentChapter === parent.parentChapter)
.map((sub) => {
const isActive = sub.slug === currentSlug;
return (
<Link
key={sub.slug}
href={`/books/sva/${sub.slug}`}
className={cn(
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={sub.label}
>
{sub.label}
</Link>
);
})}
</div>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: SvaSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/sva/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function SvaNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: SvaSidebarItem | null;
next: SvaSidebarItem | null;
sidebarItems: SvaSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatter = sidebarItems.filter((i) => i.type === "intro-segment");
if (frontMatter.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatter.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/sva/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter(
(i) => i.type === "parent" || i.type === "sub-chapter",
);
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Songs",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/sva/${i.slug}`,
type: i.type,
parentChapter: i.parentChapter,
isParent: i.type === "parent",
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Songs of the Vaiṣṇava Ācāryas"
/>
{prev ? (
<Link
href={`/books/sva/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/sva/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getSvaMeta, getSvaIntroSegments } from "@/lib/sva";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("sva");
}
export default async function SvaPage() {
const meta = await getSvaMeta();
if (!meta) notFound();
const introSegments = await getSvaIntroSegments();
// Split intro segments into front matter (before chapters) and back matter (after)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts — hierarchical parent → song (sub-chapter) structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Part {ch.number}: {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/sva/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+345
View File
@@ -0,0 +1,345 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTlcContent,
getTlcSidebarItems,
type TlcChapter,
type TlcSidebarItem,
} from "@/lib/tlc";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("tlc", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["tlc"], quote: hlQuote });
}
return ogForChapter("tlc", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TlcChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTlcContent(slug);
if (!content) notFound();
const sidebarItems = await getTlcSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as TlcChapter).chapter === "number" &&
(content as TlcChapter).chapter !== null;
const chapterNum = isChapter ? (content as TlcChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/tlc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Lord Caitanya
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<TlcSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TlcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TlcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TlcSidebar({
items,
currentSlug,
}: {
items: TlcSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
// Group chapters by part (if parts exist)
const hasParts = chapterItems.some((item) => item.partNumber != null);
const partGroups: {
partNumber: number;
partTitle: string;
chapters: TlcSidebarItem[];
}[] = [];
if (hasParts) {
const partMap = new Map<
number,
{ partTitle: string; chapters: TlcSidebarItem[] }
>();
for (const item of chapterItems) {
const pn = item.partNumber ?? 0;
const pt = item.partTitle ?? `Part ${pn}`;
if (!partMap.has(pn)) partMap.set(pn, { partTitle: pt, chapters: [] });
partMap.get(pn)!.chapters.push(item);
}
for (const [partNumber, { partTitle, chapters }] of partMap) {
partGroups.push({ partNumber, partTitle, chapters });
}
partGroups.sort((a, b) => a.partNumber - b.partNumber);
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters — grouped by part if available */}
{hasParts
? partGroups.map((group) => (
<div key={group.partNumber} className="mb-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
{group.partTitle}
</h2>
<nav className="flex flex-col gap-0.5">
{group.chapters.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</div>
))
: chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TlcSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/tlc/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function TlcNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TlcSidebarItem | null;
next: TlcSidebarItem | null;
sidebarItems: TlcSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlc/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlc/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Teachings of Lord Caitanya"
/>
{prev ? (
<Link
href={`/books/tlc/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/tlc/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTlcMeta, getTlcIntroSegments } from "@/lib/tlc";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("tlc");
}
const PART_LABELS: Record<number, string> = {
1: "Part I",
2: "Part II",
};
export default async function TlcPage() {
const meta = await getTlcMeta();
if (!meta) notFound();
const introSegments = await getTlcIntroSegments();
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">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlc/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts & Chapters */}
{meta.parts.map((part) => {
const partChapters = part.chapters
.map((num) => meta.chapters.find((ch) => ch.number === num))
.filter(Boolean) as { number: number; title: string }[];
return (
<div key={part.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{PART_LABELS[part.number] ?? `Part ${part.number}`}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{partChapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tlc/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
})}
</main>
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTlkContent,
getTlkSidebarItems,
type TlkChapter,
type TlkSidebarItem,
} from "@/lib/tlk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("tlk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["tlk"], quote: hlQuote });
}
return ogForChapter("tlk", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TlkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTlkContent(slug);
if (!content) notFound();
const sidebarItems = await getTlkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as TlkChapter).chapter === "number" &&
(content as TlkChapter).chapter !== null;
const chapterNum = isChapter ? (content as TlkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/tlk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Lord Kapila
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<TlkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TlkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TlkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TlkSidebar({
items,
currentSlug,
}: {
items: TlkSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: TlkSidebarItem[] = [];
const chapterItems: TlkSidebarItem[] = [];
const backMatterItems: TlkSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Back Matter
</h2>
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TlkSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/tlk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function TlkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TlkSidebarItem | null;
next: TlkSidebarItem | null;
sidebarItems: TlkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: TlkSidebarItem[] = [];
const chapterItems: TlkSidebarItem[] = [];
const backMatterItems: TlkSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Teachings of Lord Kapila"
/>
{prev ? (
<Link
href={`/books/tlk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/tlk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTlkMeta, getTlkIntroSegments } from "@/lib/tlk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("tlk");
}
export default async function TlkPage() {
const meta = await getTlkMeta();
if (!meta) notFound();
const introSegments = await getTlkIntroSegments();
// Split intro segments into front matter (order < 50) and back matter (order >= 50)
const frontMatter = introSegments.filter((seg) => seg.order < 50);
const backMatter = introSegments.filter((seg) => seg.order >= 50);
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tlk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTqkContent,
getTqkSidebarItems,
type TqkChapter,
type TqkSidebarItem,
} from "@/lib/tqk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("tqk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["tqk"], quote: hlQuote });
}
return ogForChapter("tqk", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TqkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTqkContent(slug);
if (!content) notFound();
const sidebarItems = await getTqkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as TqkChapter).chapter === "number" &&
(content as TqkChapter).chapter !== null;
const chapterNum = isChapter ? (content as TqkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/tqk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Queen Kuntī
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<TqkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TqkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TqkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TqkSidebar({
items,
currentSlug,
}: {
items: TqkSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: TqkSidebarItem[] = [];
const chapterItems: TqkSidebarItem[] = [];
const backMatterItems: TqkSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Back Matter
</h2>
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TqkSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/tqk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function TqkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TqkSidebarItem | null;
next: TqkSidebarItem | null;
sidebarItems: TqkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: TqkSidebarItem[] = [];
const chapterItems: TqkSidebarItem[] = [];
const backMatterItems: TqkSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Teachings of Queen Kuntī"
/>
{prev ? (
<Link
href={`/books/tqk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/tqk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTqkMeta, getTqkIntroSegments } from "@/lib/tqk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("tqk");
}
export default async function TqkPage() {
const meta = await getTqkMeta();
if (!meta) notFound();
const introSegments = await getTqkIntroSegments();
// Split intro segments into front matter (order < 50) and back matter (order >= 50)
const frontMatter = introSegments.filter((seg) => seg.order < 50);
const backMatter = introSegments.filter((seg) => seg.order >= 50);
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tqk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTtpContent,
getTtpSidebarItems,
type TtpChapter,
type TtpSidebarItem,
} from "@/lib/ttp";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ttp", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ttp"], quote: hlQuote });
}
return ogForChapter("ttp", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TtpChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTtpContent(slug);
if (!content) notFound();
const sidebarItems = await getTtpSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as TtpChapter).chapter === "number" &&
(content as TtpChapter).chapter !== null;
const chapterNum = isChapter ? (content as TtpChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ttp"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Transcendental Teachings of Prahlāda Mahārāja
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<TtpSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TtpNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TtpNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TtpSidebar({
items,
currentSlug,
}: {
items: TtpSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TtpSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ttp/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function TtpNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TtpSidebarItem | null;
next: TtpSidebarItem | null;
sidebarItems: TtpSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ttp/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ttp/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Transcendental Teachings of Prahlāda Mahārāja"
/>
{prev ? (
<Link
href={`/books/ttp/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ttp/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTtpMeta } from "@/lib/ttp";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ttp");
}
export default async function TtpPage() {
const meta = await getTtpMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ttp/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 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>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getVaContent,
getVaSidebarItems,
type VaChapter,
type VaSidebarItem,
} from "@/lib/va";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("va", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["va"], quote: hlQuote });
}
return ogForChapter("va", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function VaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getVaContent(slug);
if (!content) notFound();
const sidebarItems = await getVaSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as VaChapter).chapter;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/va"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Vaiśiṣṭyāṣṭakam
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<VaSidebar items={sidebarItems} currentSlug={slug} />
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
<VaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
<VaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
function VaSidebar({
items,
currentSlug,
}: {
items: VaSidebarItem[];
currentSlug: string;
}) {
return (
<aside className="hidden lg:block w-56 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">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{items.map((item) => (
<Link
key={item.slug}
href={`/books/va/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
item.slug === currentSlug
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
))}
</nav>
</aside>
);
}
function VaNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: VaSidebarItem | null;
next: VaSidebarItem | null;
sidebarItems: VaSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
if (sidebarItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: sidebarItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/va/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Vaiśiṣṭyāṣṭakam"
/>
{prev ? (
<Link
href={`/books/va/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/va/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getVaMeta } from "@/lib/va";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("va");
}
export default async function VaPage() {
const meta = await getVaMeta();
if (!meta) notFound();
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">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/va/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}