feat: add policy pages
This commit is contained in:
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Ś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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Ś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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Ś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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← {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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Ś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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Ś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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← {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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Light of the Bhā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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← 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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Ś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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← Ś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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user