feat: add policy pages

This commit is contained in:
gosangam
2026-06-02 01:57:08 +05:30
parent 0352cb2003
commit 91d5d272d2
168 changed files with 1326 additions and 81 deletions
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { auth, AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await auth.forgotPassword(email);
setSubmitted(true);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Something went wrong.");
} finally {
setSubmitting(false);
}
}
if (submitted) {
return (
<AuthCard
title="Check your inbox"
description="If an account exists for that email, we've sent a reset link. It will expire in 1 hour."
footer={<AuthFooterLink prefix="Back to" href="/login" label="log in" />}
>
<p className="text-sm text-muted-foreground">
Didn&apos;t get an email? Check your spam folder or try again in a minute.
</p>
</AuthCard>
);
}
return (
<AuthCard
title="Reset your password"
description="Enter your email and we'll send you a reset link."
footer={<AuthFooterLink prefix="Remembered it?" href="/login" label="Log in" />}
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Sending…" : "Send reset link"}
</Button>
</form>
</AuthCard>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Shared layout for the auth pages (login, signup, forgot-password,
* reset-password). Provides the centered card frame so each page only
* has to render its own form.
*/
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="flex items-center justify-center px-4 py-12 min-h-[90vh]">
<div className="w-full max-w-md">{children}</div>
</main>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { LoginForm } from "@/components/auth/login-form";
function LoginContent() {
const router = useRouter();
const params = useSearchParams();
const next = params.get("next") ?? "/u/me";
const oauthError = params.get("oauth_error");
return (
<AuthCard
title="Welcome back"
description="Log in to access your bookmarks, notes, and collections."
footer={
<AuthFooterLink prefix="No account?" href="/signup" label="Create one" />
}
>
{oauthError ? (
<p className="text-sm text-destructive">
{oauthErrorMessage(oauthError)}
</p>
) : null}
<LoginForm
oauthNext={next}
onSuccess={() => {
router.push(next);
router.refresh();
}}
/>
</AuthCard>
);
}
function oauthErrorMessage(code: string): string {
switch (code) {
case "email_required":
return "We couldn't sign you in because the provider didn't share an email. Please use the email/password form below.";
case "invalid_state":
case "missing_state":
return "Your sign-in link expired. Please try again.";
case "exchange_failed":
case "profile_failed":
return "Sign-in failed while talking to the provider. Please try again.";
case "unknown_provider":
return "That sign-in provider isn't configured. Please use email or another method.";
case "unavailable":
return "Social sign-in is temporarily unavailable. Please use email/password.";
case "server":
return "Something went wrong on our side. Please try again in a moment.";
default:
return "Sign-in failed. Please try again.";
}
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginContent />
</Suspense>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { auth, AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function ResetPasswordForm() {
const params = useSearchParams();
const router = useRouter();
const token = params.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
if (!token) {
return (
<AuthCard title="Invalid reset link" description="The link is missing a token. Please request a new one.">
<AuthFooterLink prefix="Need a new link?" href="/forgot-password" label="Request one" />
</AuthCard>
);
}
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setSubmitting(true);
try {
await auth.resetPassword(token, password);
setDone(true);
setTimeout(() => router.push("/login"), 1500);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Reset failed.");
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<AuthCard
title="Password updated"
description="Redirecting you to the login page…"
>
<AuthFooterLink prefix="Take me there now —" href="/login" label="Log in" />
</AuthCard>
);
}
return (
<AuthCard
title="Set a new password"
description="Choose a strong password you haven't used before."
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
autoComplete="new-password"
minLength={8}
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Updating…" : "Update password"}
</Button>
</form>
</AuthCard>
);
}
export default function ResetPasswordPage() {
// useSearchParams must be inside Suspense for Next.js streaming.
return (
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { SignUpForm } from "@/components/auth/signup-form";
export default function SignUpPage() {
const router = useRouter();
return (
<AuthCard
title="Create your account"
description="Save bookmarks, build collections, and follow Śrīla Prabhupāda's words across devices."
footer={
<AuthFooterLink prefix="Already registered?" href="/login" label="Log in" />
}
>
<SignUpForm
oauthNext="/u/me"
onSuccess={() => {
router.push("/u/me");
router.refresh();
}}
/>
</AuthCard>
);
}
+230
View File
@@ -0,0 +1,230 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og";
import {
BookOpen,
Users,
BrainCircuit,
Blocks,
Circle,
CircleCheck,
Clock
} from "lucide-react";
const PHASES = [
{
title: "Phase I — The Vedabase",
status: "done" as const,
icon: BookOpen,
description:
"Indexing the complete pre-1980s Vedabase into a structured, searchable database — books, letters, essays, magazines, journals, glossary, and disciple records — with a clean, accessible reading interface.",
highlights: [
"Verse-by-verse editions with Devanagari, transliteration, synonyms, translations & purports",
"6,800+ letters browsable by year, recipient, and location",
"4,700+ initiated disciples indexed with initiation details",
"Comprehensive glossary of 2,700+ Sanskrit & Vedic terms",
"Accessibility features — adjustable font size, line height, and theme",
],
},
{
title: "Phase II — Devotee Features",
status: "in-progress" as const,
icon: Users,
description:
"Adding user-centric features so devotees can engage with the archive personally — sign in, save favourite quotes, build collections, share with attribution, and tag someone to a specific text.",
highlights: [
"Account sign-in and personal profiles",
"Save and organise quotes into collections",
"Share quotes with rich attribution and highlight links",
"Bookmark verses, letters, and lectures for later reading",
"Mention on text — tag someone to look into your selected text",
],
},
{
title: "Phase III — AI Companion",
status: "upcoming" as const,
icon: BrainCircuit,
description:
"Training and integrating an AI companion grounded entirely in Śrīla Prabhupāda's teachings — designed to help devotees study, cross-reference, and deepen their understanding of the philosophy.",
highlights: [
"Answers rooted exclusively in Śrīla Prabhupāda's books and lectures",
"Cross-referencing across books, letters, and conversations",
"Guided study paths for new and experienced devotees",
],
},
{
title: "Phase IV — Decentralized Preservation",
status: "upcoming" as const,
icon: Blocks,
description:
"Deploying the Vedabase onto blockchain infrastructure to ensure the teachings remain permanently accessible, tamper-proof, and beyond the control of any single entity.",
highlights: [],
},
];
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "About — Prabhupāda Says",
quote:
"Built by a small team of Rupānugas — preserving and presenting the teachings of Śrīla Prabhupāda exactly as he gave them, without alteration.",
});
}
export default function AboutPage() {
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">About</h1>
<p className="mt-1 text-muted-foreground">
Who we are and what we&apos;re building
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-10 space-y-14">
{/* ---- Mission ---- */}
<section className="space-y-4">
<h2 className="text-2xl font-bold text-foreground">Our Mission</h2>
<div className="space-y-4 text-[15px] leading-relaxed text-muted-foreground">
<p>
<span className="font-medium text-foreground">
Prabhupāda Says
</span>{" "}
is built by a small team of Rupānugas. We are dedicated
to preserving and presenting the teachings of His Divine Grace
A.C. Bhaktivedanta Swami Prabhupāda exactly as he gave them,
without alteration, reinterpretation, or institutional bias.
</p>
<p>
Our sole purpose is to make Śrīla Prabhupāda&apos;s <em>vāṇī</em>{" "}
— his words, books, letters, and instructions — freely available
to sincere seekers everywhere, presented as faithfully as possible
in a modern, accessible format.
</p>
<p>
We are an independent project. We are not affiliated with, nor do
we represent or promote the views of, any institution, governing
body, or faction within the broader Vaiṣṇava community. Our work
speaks for itself: Śrīla Prabhupāda&apos;s words, as they are.
</p>
</div>
</section>
{/* ---- Data Source ---- */}
<section className="space-y-4">
<h2 className="text-2xl font-bold text-foreground">About the Data</h2>
<div className="space-y-4 text-[15px] leading-relaxed text-muted-foreground">
<p>
The content on this site is sourced from the{" "}
<span className="font-medium text-foreground">
pre-1980s Vedabase
</span>{" "}
— the original, unrevised collection of Śrīla Prabhupāda&apos;s
literary and personal works compiled during and shortly after his
physical presence.
</p>
<p>
We are aware that additional letters, documents, conversations,
and initiated disciples&apos; records were discovered in the years
following 1980. Some of this material may not yet be reflected on
Prabhupāda Says. We are actively working to verify, index, and
incorporate these documents into our database as quickly and
carefully as possible.
</p>
<p>
If you have access to original documents, corrections, or
historical information that could improve the accuracy of this
archive, we welcome your contribution.
</p>
</div>
</section>
{/* ---- Roadmap ---- */}
<section className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-foreground">Roadmap</h2>
<p className="mt-1 text-[15px] text-muted-foreground">
Our work is organized into four phases — each building on the
last.
</p>
</div>
<div className="relative space-y-0">
{/* Vertical timeline line */}
<div className="absolute left-4.75 top-6 bottom-6 w-px bg-border" />
{PHASES.map((phase, i) => {
const Icon = phase.icon;
const isDone = phase.status === "done";
const isInProgress = phase.status === "in-progress";
return (
<div key={i} className="relative flex gap-5 pb-10 last:pb-0">
{/* Timeline node */}
<div className="relative z-10 flex items-center justify-center size-10 shrink-0 rounded-full bg-background ring-1 ring-border">
{isDone ? (
<Icon className="size-4 text-primary" />
) : isInProgress ? (
<div className="relative">
<Icon className="size-4 text-primary" />
<span className="absolute -top-1 -right-1 size-2.5 rounded-full bg-primary animate-pulse" />
</div>
) : (
<Icon className="size-4 text-muted-foreground" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 space-y-3 pt-1.5">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-lg font-semibold text-foreground">
{phase.title}
</h3>
{isDone ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/15 px-2.5 py-0.5 text-xs font-medium text-primary">
<CircleCheck className="size-3" />
Complete
</span>
) : isInProgress ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
<Clock className="size-3" />
In Progress
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground">
<Circle className="size-3" />
Upcoming
</span>
)}
</div>
<p className="text-[15px] leading-relaxed text-muted-foreground">
{phase.description}
</p>
{phase.highlights.length > 0 && (
<ul className="space-y-1.5 text-sm text-muted-foreground">
{phase.highlights.map((item, j) => (
<li key={j} className="flex gap-2">
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-primary/50" />
<span>{item}</span>
</li>
))}
</ul>
)}
</div>
</div>
);
})}
</div>
</section>
</main>
</div>
);
}
+299
View File
@@ -0,0 +1,299 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getBbdContent,
getBbdSidebarItems,
type BbdChapter,
type BbdSidebarItem,
} from "@/lib/bbd";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("bbd", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["bbd"], quote: hlQuote });
}
return ogForChapter("bbd", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function BbdChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getBbdContent(slug);
if (!content) notFound();
const sidebarItems = await getBbdSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as BbdChapter).chapter === "number" &&
(content as BbdChapter).chapter !== null;
const chapterNum = isChapter ? (content as BbdChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bbd"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Beyond Birth and Death
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<BbdSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<BbdNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<BbdNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function BbdSidebar({
items,
currentSlug,
}: {
items: BbdSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: BbdSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/bbd/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function BbdNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: BbdSidebarItem | null;
next: BbdSidebarItem | null;
sidebarItems: BbdSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/bbd/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/bbd/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Beyond Birth and Death"
/>
{prev ? (
<Link
href={`/books/bbd/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/bbd/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBbdMeta, getBbdIntroSegments } from "@/lib/bbd";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("bbd");
}
export default async function BbdPage() {
const meta = await getBbdMeta();
if (!meta) notFound();
const introSegments = await getBbdIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/bbd/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bbd/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"bg",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Bhagavad-gītā As It Is",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("bg", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
type BgMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const meta = await getBookMeta("bg");
if (!meta) notFound();
const bgMeta = meta as unknown as BgMeta;
const chapterInfo = bgMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getSingleVerse(
"bg",
null,
parseInt(chapter),
requestedVerse,
);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/bg/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bg"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Bhagavad-gītā
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="bg"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/bg/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({ title: BOOK_NAMES["bg"], detail: `Chapter ${chapter}` });
}
export default async function ChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/bg/${chapter}/${verseIndex[0].verse}`);
}
+74
View File
@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
export function generateMetadata(): Metadata {
return ogForBook("bg");
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
export default async function BgPage() {
const meta = await getBookMeta("bg");
if (!meta) notFound();
const intro = meta as unknown as {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: ChapterInfo[];
};
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{intro.title}
</h1>
<p className="mt-1 text-muted-foreground">{intro.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{intro.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">{ch.title}</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getBkContent,
getBkSidebarItems,
type BkChapter,
type BkSidebarItem,
} from "@/lib/bk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("bk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["bk"], quote: hlQuote });
}
return ogForChapter("bk", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function BkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getBkContent(slug);
if (!content) notFound();
const sidebarItems = await getBkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const chapterNum = (content as BkChapter).chapter ?? null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Bhagavāner Kathā
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<BkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<BkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<BkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function BkSidebar({
items,
currentSlug,
}: {
items: BkSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: BkSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/bk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function BkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: BkSidebarItem | null;
next: BkSidebarItem | null;
sidebarItems: BkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/bk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Bhagavāner Kathā"
/>
{prev ? (
<Link
href={`/books/bk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/bk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBkMeta } from "@/lib/bk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("bk");
}
export default async function BkPage() {
const meta = await getBkMeta();
if (!meta) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
import { ExtractedVerseCount } from "./extracted-verse-count";
export type BookInfo = {
slug: string;
title: string;
author: string;
description: string;
contentDescription: string;
totalVerses: number;
href: string;
};
/**
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
*
* Uses Unicode NFD decomposition to split base characters from
* combining marks, then removes the marks. Also handles special
* IAST characters that don't decompose cleanly via NFD.
*/
function normalize(text: string): string {
return (
text
.toLowerCase()
// Handle IAST characters that NFD doesn't decompose into ASCII base
.replace(/ṛ/g, "r")
.replace(/ṝ/g, "r")
.replace(/ṣ/g, "s")
.replace(/ś/g, "s")
.replace(/ṭ/g, "t")
.replace(/ḍ/g, "d")
.replace(/ṇ/g, "n")
.replace(/ñ/g, "n")
.replace(/ṅ/g, "n")
.replace(/ḥ/g, "h")
.replace(/ṁ/g, "m")
.replace(/ṃ/g, "m")
.replace(/ḷ/g, "l")
// NFD decompose + strip combining marks (handles ā→a, ī→i, ū→u, etc.)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
);
}
export function BooksGrid({ books }: { books: BookInfo[] }) {
const [query, setQuery] = useState("");
const normalizedBooks = useMemo(
() =>
books.map((book) => ({
book,
searchText: normalize(
`${book.title} ${book.contentDescription} ${book.description} ${book.slug}`,
),
})),
[books],
);
const filtered = useMemo(() => {
if (!query.trim()) return books;
const q = normalize(query.trim());
return normalizedBooks
.filter((entry) => entry.searchText.includes(q))
.map((entry) => entry.book);
}, [books, normalizedBooks, query]);
return (
<div className="space-y-6">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search books by name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Results count when searching */}
{query.trim() && (
<p className="text-sm text-muted-foreground">
{filtered.length === 0
? "No books found"
: `${filtered.length} book${filtered.length === 1 ? "" : "s"} found`}
</p>
)}
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((book) => (
<Link key={book.slug} href={book.href} className="block h-full">
<Card
className={cn(
"h-full transition-all hover:shadow-md hover:ring-foreground/20",
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold leading-snug">
{book.title}
</CardTitle>
{book.contentDescription && (
<CardDescription className="text-xs line-clamp-2">
{book.contentDescription}
</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{book.description && (
<Badge variant="secondary" className="text-xs">
{book.description}
</Badge>
)}
{book.totalVerses > 0 && (
<ExtractedVerseCount
count={book.totalVerses}
label="verses"
className="text-muted-foreground text-xs"
/>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,215 @@
import type { Metadata } from "next";
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
type BsMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("bs");
if (meta) return meta as unknown as BsMeta;
const introPath = path.join(BS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("bs", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("bs", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `bs-${chapter}-${verse}`,
book: "bs",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getVerseWithFallback(parseInt(chapter), parseInt(verse));
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrī Brahma-saṁhitā",
detail: `Chapter ${chapter}, Verse ${parseInt(verse)}`,
quote,
});
}
return ogForVerse("bs", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const bsMeta = await getMetaWithFallback();
if (!bsMeta) notFound();
const chapterInfo = bsMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/bs/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bs"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrī Brahma-saṁhitā
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="bs"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/bs/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({ title: BOOK_NAMES["bs"], detail: `Chapter ${chapter}` });
}
export default async function BsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction, intro-foreword)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(BS_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Brahma-saṁhitā
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("bs", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/bs/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/bs/${chapter}/${verseIndex[0].verse}`);
}
+180
View File
@@ -0,0 +1,180 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
export function generateMetadata(): Metadata {
return ogForBook("bs");
}
type BsMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type BsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
async function getMetaWithFallback(): Promise<BsMeta | null> {
const meta = await getBookMeta("bs");
if (meta) return meta as unknown as BsMeta;
const introPath = path.join(BS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): BsIntroSegment[] {
const introDir = path.join(BS_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: BsIntroSegment[] = [];
const files = ["introduction.json", "foreword.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as BsIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export default async function BsPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
const foreword = introSegments.find((s) => s.slug === "foreword");
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{(introduction || foreword) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introduction && (
<li>
<Link
href="/books/bs/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{foreword && (
<li>
<Link
href="/books/bs/intro-foreword"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{foreword.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bs/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getCbhcContent,
getCbhcSidebarItems,
type CbhcChapter,
type CbhcSidebarItem,
} from "@/lib/cbhc";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("cbhc", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["cbhc"], quote: hlQuote });
}
return ogForChapter("cbhc", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function CbhcChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getCbhcContent(slug);
if (!content) notFound();
const sidebarItems = await getCbhcSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const chapterNum = (content as CbhcChapter).chapter ?? null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/cbhc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Caitanya-bhāgavata Commentary
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<CbhcSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<CbhcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<CbhcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function CbhcSidebar({
items,
currentSlug,
}: {
items: CbhcSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: CbhcSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/cbhc/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function CbhcNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: CbhcSidebarItem | null;
next: CbhcSidebarItem | null;
sidebarItems: CbhcSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/cbhc/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Śrī Caitanya-bhāgavata Commentary"
/>
{prev ? (
<Link
href={`/books/cbhc/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/cbhc/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getCbhcMeta } from "@/lib/cbhc";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("cbhc");
}
export default async function CbhcPage() {
const meta = await getCbhcMeta();
if (!meta) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/cbhc/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,139 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ lila: string; chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { lila, chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"cc",
lila,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
const bookTitle = "Śrī Caitanya-caritāmṛta";
return buildOgMeta({
title: bookTitle,
detail: `${lila.charAt(0).toUpperCase() + lila.slice(1)}, Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("cc", {
division: lila,
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function CcVersePage({
params,
}: {
params: Promise<{ lila: string; chapter: string; verse: string }>;
}) {
const { lila, chapter, verse } = await params;
const meta = await getBookMeta("cc");
if (!meta) notFound();
const lilas = meta.lilas as LilaInfo[] | undefined;
const lilaInfo = lilas?.find((l) => l.key === lila);
if (!lilaInfo) notFound();
const chapterInfo = lilaInfo.chapters.find(
(ch) => ch.number === parseInt(chapter),
);
if (!chapterInfo) notFound();
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verse);
const verseData = await getSingleVerse(
"cc",
lila,
parseInt(chapter),
requestedVerse,
);
if (!verseData) notFound();
// Build mobile nav groups from verse index
const mobileNavGroups: MobileNavGroup[] = [];
if (verseIndex.length > 0) {
mobileNavGroups.push({
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/cc/${lila}/${chapter}/${v.verse}`,
type: "verse",
})),
});
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href={`/books/cc/${lila}`}
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; {lilaInfo.name}
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {chapterInfo.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verseData}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="cc"
division={lila}
chapter={parseInt(chapter)}
showBengali={true}
basePath={`/books/cc/${lila}/${chapter}`}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
/>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { CC_LILAS } from "@/lib/constants";
import { buildOgMeta } from "@/lib/og";
export async function generateMetadata({
params,
}: {
params: Promise<{ lila: string; chapter: string }>;
}): Promise<Metadata> {
const { lila, chapter } = await params;
const lilaName = CC_LILAS[lila] || lila;
return buildOgMeta({
title: "Śrī Caitanya-caritāmṛta",
detail: `${lilaName}, Chapter ${chapter}`,
});
}
export default async function CcChapterPage({
params,
}: {
params: Promise<{ lila: string; chapter: string }>;
}) {
const { lila, chapter } = await params;
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/cc/${lila}/${chapter}/${verseIndex[0].verse}`);
}
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../../extracted-verse-count";
import { getBookMeta } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
export async function generateMetadata({
params,
}: {
params: Promise<{ lila: string }>;
}): Promise<Metadata> {
const { lila } = await params;
const lilaName = CC_LILAS[lila] || lila;
const bookTitle = BOOK_NAMES["cc"] || "Śrī Caitanya-caritāmṛta";
return buildOgMeta({
title: bookTitle,
detail: lilaName,
});
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export default async function LilaPage({
params,
}: {
params: Promise<{ lila: string }>;
}) {
const { lila } = await params;
const meta = await getBookMeta("cc");
if (!meta) notFound();
const lilas = meta.lilas as LilaInfo[];
const lilaInfo = lilas.find((l) => l.key === lila);
if (!lilaInfo) notFound();
const totalVerses = lilaInfo.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/cc"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrī Caitanya-caritāmṛta
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{lilaInfo.name}
</h1>
<p className="mt-1 text-muted-foreground">
{lilaInfo.totalChapters} chapters ·{" "}
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{lilaInfo.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/cc/${lila}/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">{ch.title}</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ExtractedVerseCount } from "../extracted-verse-count";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export function generateMetadata(): Metadata {
return ogForBook("cc");
}
export default async function CcPage() {
const meta = await getBookMeta("cc");
if (!meta) {
return (
<div className="p-8 text-center text-muted-foreground">
Book metadata not found.
</div>
);
}
const lilas = meta.lilas as LilaInfo[];
const title = meta.title as string;
const author = meta.author as string;
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
<p className="mt-1 text-muted-foreground">{author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{lilas.map((lila) => {
const totalVerses = lila.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<li key={lila.key}>
<Link
href={`/books/cc/${lila.key}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
{lila.name}
</Badge>
<p className="font-medium text-foreground">
{lila.totalChapters} chapters
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount
count={totalVerses}
label="extracted verses"
/>
</span>
</Link>
</li>
);
})}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+471
View File
@@ -0,0 +1,471 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useEffect, useState } from "react";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Synonym = {
word: string;
meanings: string[];
};
type Verse = {
id: string;
book: string;
division: string | null;
chapter: number;
verse: number;
verseEnd: number | null;
reference: string;
bookTitle: string;
devanagari: string;
bengali: string | null;
transliteration: string;
synonyms: Synonym[];
translation: string;
purport: string[];
};
type VerseIndexEntry = {
verse: number;
verseEnd?: number;
reference: string;
};
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
type ChapterViewerProps = {
/** The first verse (SSR'd), shown before any client-side fetch */
initialVerse: Verse;
/** The verse number of the initial verse */
initialVerseNum: number;
/** Lightweight index of ALL verses in the chapter (for the sidebar) */
verseIndex: VerseIndexEntry[];
/** Book key */
book: string;
/** Division: null for BG, canto string for SB, lila key for CC */
division: string | null;
/** Chapter number */
chapter: number;
/** Whether to show the Bengali script field (CC only) */
showBengali?: boolean;
/** Base path for verse URLs, e.g. "/books/bg/1" — verse number is appended */
basePath: string;
/** Mobile nav groups for the drawer */
mobileNavGroups?: MobileNavGroup[];
/** Book title for the mobile nav drawer header */
mobileNavBookTitle?: string;
};
// ---------------------------------------------------------------------------
// Navigation buttons (reused at top and bottom)
// ---------------------------------------------------------------------------
function VerseNav({
currentIdx,
total,
onPrev,
onNext,
verseIndex,
mobileNavGroups,
currentVerseSlug,
mobileNavBookTitle,
}: {
currentIdx: number;
total: number;
onPrev: () => void;
onNext: () => void;
verseIndex: VerseIndexEntry[];
mobileNavGroups?: MobileNavGroup[];
currentVerseSlug?: string;
mobileNavBookTitle?: string;
}) {
const hasPrev = currentIdx > 0;
const hasNext = currentIdx < total - 1;
const current = verseIndex[currentIdx];
const label = current
? current.verseEnd
? `${current.verse}–${current.verseEnd}`
: String(current.verse)
: "";
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
{mobileNavGroups && mobileNavGroups.length > 0 && (
<MobileNav
groups={mobileNavGroups}
currentSlug={currentVerseSlug ?? String(current?.verse ?? "")}
bookTitle={mobileNavBookTitle}
/>
)}
<Button
variant="outline"
size="sm"
disabled={!hasPrev}
onClick={onPrev}
className="gap-1.5"
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline">Previous</span>
</Button>
</div>
<span className="text-sm text-muted-foreground">
Verse {label} of {total}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasNext}
onClick={onNext}
className="gap-1.5"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="size-4" />
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Verse card
// ---------------------------------------------------------------------------
function VerseCard({
verse,
showBengali,
}: {
verse: Verse;
showBengali: boolean;
}) {
return (
<Card id={`verse-${verse.verse}`} className="">
<CardHeader className="border-b border-border bg-muted/50 py-3">
<Badge variant="secondary">{verse.reference}</Badge>
</CardHeader>
<CardContent className="space-y-5 pt-5">
{/* Bengali (CC only) */}
{showBengali && verse.bengali && (
<div className="text-center">
<p className="text-xl leading-relaxed whitespace-pre-line font-serif text-green-800 dark:text-green-400">
{verse.bengali}
</p>
</div>
)}
{/* Devanagari */}
<div className="text-center">
<p className="text-xl leading-relaxed text-red-800 dark:text-white whitespace-pre-line font-serif">
{verse.devanagari}
</p>
</div>
{/* Transliteration */}
<div className="text-center">
<p className="text-base italic text-muted-foreground whitespace-pre-line">
{verse.transliteration}
</p>
</div>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<div>
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
Synonyms
</h3>
<p className="text-base text-foreground leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
<span className="italic text-foreground">{s.word}</span>—
{s.meanings.join(", ")}
{i < verse.synonyms.length - 1 ? "; " : "."}
</span>
))}
</p>
</div>
)}
{/* Translation */}
<div>
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
Translation
</h3>
<p
className="text-base text-foreground leading-relaxed font-medium"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</div>
{/* Purport */}
{verse.purport.length > 0 && (
<div>
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
Purport
</h3>
<div className="text-base text-foreground leading-relaxed space-y-3">
{verse.purport.map((p, i) => (
<p key={i} dangerouslySetInnerHTML={{ __html: p }} />
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// ChapterViewer
// ---------------------------------------------------------------------------
export function ChapterViewer({
initialVerse,
initialVerseNum,
verseIndex,
book,
division,
chapter,
showBengali = false,
basePath,
mobileNavGroups,
mobileNavBookTitle,
}: ChapterViewerProps) {
const router = useRouter();
// Verse cache: verseNum → Verse data
const [cache, setCache] = useState<Map<number, Verse>>(() => {
const map = new Map<number, Verse>();
map.set(initialVerseNum, initialVerse);
return map;
});
const [loading, setLoading] = useState(false);
// Current verse number — driven by the initialVerseNum from the server
// (which reads it from the [verse] path param)
const currentVerseNum = initialVerseNum;
// Current index in the verseIndex array
const currentIdx = useMemo(
() => verseIndex.findIndex((v) => v.verse === currentVerseNum),
[verseIndex, currentVerseNum],
);
// The verse data to display
const currentVerse = cache.get(currentVerseNum) ?? null;
// Reset cache when route changes to a different chapter
useEffect(() => {
const map = new Map<number, Verse>();
map.set(initialVerseNum, initialVerse);
setCache(map);
}, [initialVerse, initialVerseNum]);
// Fetch a verse if it's not in the cache
const fetchVerse = useCallback(
async (verseNum: number) => {
if (cache.has(verseNum)) return cache.get(verseNum)!;
setLoading(true);
const params = new URLSearchParams({
chapter: String(chapter),
verse: String(verseNum),
});
if (division !== null) {
params.set("division", division);
}
try {
const res = await fetch(`/api/books/${book}/verses?${params}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json();
// The API returns the verse in a paginated wrapper or an array
const verse: Verse | undefined = Array.isArray(data.verses)
? data.verses[0]
: Array.isArray(data)
? data[0]
: undefined;
if (verse) {
setCache((prev) => {
const next = new Map(prev);
next.set(verseNum, verse);
return next;
});
return verse;
}
} catch (err) {
console.error("Failed to fetch verse:", err);
} finally {
setLoading(false);
}
return null;
},
[cache, book, division, chapter],
);
// Navigate to a verse — update the URL path and fetch if needed
const navigateTo = useCallback(
async (verseNum: number) => {
// Prefetch the verse data before navigating
if (!cache.has(verseNum)) {
await fetchVerse(verseNum);
}
// Navigate to the new path-based URL
router.push(`${basePath}/${verseNum}`, { scroll: false });
// Scroll to top of the verse
window.scrollTo({ top: 0, behavior: "smooth" });
},
[basePath, router, cache, fetchVerse],
);
// Fetch current verse on mount/URL change if not cached
useEffect(() => {
if (!cache.has(currentVerseNum)) {
fetchVerse(currentVerseNum);
}
}, [currentVerseNum, cache, fetchVerse]);
// Prefetch adjacent verses
useEffect(() => {
if (currentIdx > 0) {
const prevNum = verseIndex[currentIdx - 1].verse;
if (!cache.has(prevNum)) fetchVerse(prevNum);
}
if (currentIdx < verseIndex.length - 1) {
const nextNum = verseIndex[currentIdx + 1].verse;
if (!cache.has(nextNum)) fetchVerse(nextNum);
}
}, [currentIdx, verseIndex, cache, fetchVerse]);
// Keyboard navigation
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
if (e.key === "ArrowLeft" && currentIdx > 0) {
e.preventDefault();
navigateTo(verseIndex[currentIdx - 1].verse);
} else if (e.key === "ArrowRight" && currentIdx < verseIndex.length - 1) {
e.preventDefault();
navigateTo(verseIndex[currentIdx + 1].verse);
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [currentIdx, verseIndex, navigateTo]);
const goPrev = useCallback(() => {
if (currentIdx > 0) navigateTo(verseIndex[currentIdx - 1].verse);
}, [currentIdx, verseIndex, navigateTo]);
const goNext = useCallback(() => {
if (currentIdx < verseIndex.length - 1)
navigateTo(verseIndex[currentIdx + 1].verse);
}, [currentIdx, verseIndex, navigateTo]);
return (
<>
{/* ---- Sidebar: Verse Index ---- */}
{/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for
the site footer so the sidebar doesn't slide behind it when the
page is scrolled to the bottom. (89 sticky-top + 76 bottom gap
= 165, hence calc(100vh - 165px).) */}
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-165px)] overflow-y-auto py-8 pr-4">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Verses
</h2>
<nav className="grid grid-cols-3 gap-1">
{verseIndex.map((v) => {
const isActive = currentVerseNum === v.verse;
const label = v.verseEnd
? `${v.verse}–${v.verseEnd}`
: String(v.verse);
return (
<a
key={v.verse}
href={`${basePath}/${v.verse}`}
onClick={(e) => {
e.preventDefault();
navigateTo(v.verse);
}}
className={`px-2 py-1 rounded-md text-sm tabular-nums transition-colors ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{label}
</a>
);
})}
</nav>
</aside>
{/* ---- Main: Single Verse ---- */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-6">
{/* Top navigation */}
<VerseNav
currentIdx={currentIdx}
total={verseIndex.length}
onPrev={goPrev}
onNext={goNext}
verseIndex={verseIndex}
mobileNavGroups={mobileNavGroups}
currentVerseSlug={String(currentVerseNum)}
mobileNavBookTitle={mobileNavBookTitle}
/>
{/* Verse content */}
{loading && !currentVerse ? (
<div className="flex items-center justify-center py-24 text-muted-foreground gap-2">
<Loader2 className="size-5 animate-spin" />
<span>Loading verse…</span>
</div>
) : currentVerse ? (
<VerseCard verse={currentVerse} showBengali={showBengali} />
) : (
<div className="text-center py-24 text-muted-foreground">
Verse not found.
</div>
)}
{/* Bottom navigation */}
<VerseNav
currentIdx={currentIdx}
total={verseIndex.length}
onPrev={goPrev}
onNext={goNext}
verseIndex={verseIndex}
mobileNavGroups={mobileNavGroups}
currentVerseSlug={String(currentVerseNum)}
mobileNavBookTitle={mobileNavBookTitle}
/>
</main>
</>
);
}
+331
View File
@@ -0,0 +1,331 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getDsContent,
getDsSidebarItems,
type DsChapter,
type DsSidebarItem,
} from "@/lib/ds";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ds", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ds"], quote: hlQuote });
}
return ogForChapter("ds", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function DsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getDsContent(slug);
if (!content) notFound();
const sidebarItems = await getDsSidebarItems();
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
const next =
currentIdx < navigableItems.length - 1
? navigableItems[currentIdx + 1]
: null;
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
const isSubChapter =
"parentChapter" in content &&
typeof (content as DsChapter).parentChapter === "number";
const parentChapterNum = isSubChapter
? (content as DsChapter).parentChapter
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ds"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Dialectic Spiritualism
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Chapter {parentChapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<DsSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<DsNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<DsNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function DsSidebar({
items,
currentSlug,
}: {
items: DsSidebarItem[];
currentSlug: string;
}) {
// Split items by section
const introItems = items.filter((i) => i.type === "intro-segment");
const parents = items.filter((i) => i.type === "parent");
const subChapters = items.filter((i) => i.type === "sub-chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters — grouped by parent */}
{parents.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{parents.map((parent) => (
<div key={parent.slug}>
{/* Parent heading — not a link, just a bold label */}
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
{parent.label}
</div>
{/* Sub-chapters indented */}
{subChapters
.filter((sub) => sub.parentChapter === parent.parentChapter)
.map((sub) => {
const isActive = sub.slug === currentSlug;
return (
<Link
key={sub.slug}
href={`/books/ds/${sub.slug}`}
className={cn(
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={sub.label}
>
{sub.label}
</Link>
);
})}
</div>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: DsSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ds/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function DsNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: DsSidebarItem | null;
next: DsSidebarItem | null;
sidebarItems: DsSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ds/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter(
(i) => i.type === "parent" || i.type === "sub-chapter",
);
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ds/${i.slug}`,
type: i.type,
parentChapter: i.parentChapter,
isParent: i.type === "parent",
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Dialectic Spiritualism"
/>
{prev ? (
<Link
href={`/books/ds/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ds/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getDsMeta, getDsIntroSegments } from "@/lib/ds";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ds");
}
export default async function DsPage() {
const meta = await getDsMeta();
if (!meta) notFound();
const introSegments = await getDsIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
{meta.subtitle && (
<p className="mt-1 text-lg text-muted-foreground">{meta.subtitle}</p>
)}
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ds/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters — hierarchical parent → sub-chapter structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{ch.number}. {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/ds/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getEjContent,
getEjSidebarItems,
type EjChapter,
type EjSidebarItem,
} from "@/lib/ej";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ej", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ej"], quote: hlQuote });
}
return ogForChapter("ej", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function EjChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getEjContent(slug);
if (!content) notFound();
const sidebarItems = await getEjSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as EjChapter).chapter === "number" &&
(content as EjChapter).chapter !== null;
const chapterNum = isChapter ? (content as EjChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ej"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Easy Journey to Other Planets
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<EjSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EjNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EjNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EjSidebar({
items,
currentSlug,
}: {
items: EjSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: EjSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ej/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function EjNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: EjSidebarItem | null;
next: EjSidebarItem | null;
sidebarItems: EjSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ej/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ej/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Easy Journey to Other Planets"
/>
{prev ? (
<Link
href={`/books/ej/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ej/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getEjMeta, getEjIntroSegments } from "@/lib/ej";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ej");
}
export default async function EjPage() {
const meta = await getEjMeta();
if (!meta) notFound();
const introSegments = await getEjIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ej/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ej/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getEkContent,
getEkSidebarItems,
type EkChapter,
type EkSidebarItem,
} from "@/lib/ek";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ek", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ek"], quote: hlQuote });
}
return ogForChapter("ek", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function EkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getEkContent(slug);
if (!content) notFound();
const sidebarItems = await getEkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as EkChapter).chapter === "number" &&
(content as EkChapter).chapter !== null;
const chapterNum = isChapter ? (content as EkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ek"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Elevation to Kṛṣṇa Consciousness
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<EkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EkSidebar({
items,
currentSlug,
}: {
items: EkSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: EkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ek/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function EkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: EkSidebarItem | null;
next: EkSidebarItem | null;
sidebarItems: EkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ek/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ek/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Elevation to Kṛṣṇa Consciousness"
/>
{prev ? (
<Link
href={`/books/ek/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ek/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getEkMeta, getEkIntroSegments } from "@/lib/ek";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ek");
}
export default async function EkPage() {
const meta = await getEkMeta();
if (!meta) notFound();
const introSegments = await getEkIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ek/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ek/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const TOOLTIP_TEXT =
"This is the number of extracted verse entries from Śrīla Prabhupāda's original translations and purports. Some consecutive verses are merged into a single entry (e.g. Texts 16–18), so the actual verse count in the original manuscript may differ.";
export function ExtractedVerseCount({
count,
label = "verses",
className = "",
}: {
count: number;
label?: string;
className?: string;
}) {
return (
<span className={`inline-flex items-center gap-1 ${className}`}>
<span>
{count.toLocaleString()} {label}
</span>
<Tooltip>
<TooltipTrigger
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="inline-flex items-center justify-center opacity-50 hover:opacity-80 transition-opacity"
aria-label="About verse count"
>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-pretty">
{TOOLTIP_TEXT}
</TooltipContent>
</Tooltip>
</span>
);
}
+357
View File
@@ -0,0 +1,357 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getGgContent,
getGgSidebarItems,
type GgChapter,
type GgSidebarItem,
} from "@/lib/gg";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("gg", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["gg"], quote: hlQuote });
}
return ogForChapter("gg", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function GgChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getGgContent(slug);
if (!content) notFound();
const sidebarItems = await getGgSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as GgChapter).chapter === "number" &&
(content as GgChapter).chapter !== null;
const chapterNum = isChapter ? (content as GgChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/gg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Gītār Gān
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<GgSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<GgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<GgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function GgSidebar({
items,
currentSlug,
}: {
items: GgSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: GgSidebarItem[] = [];
const chapterItems: GgSidebarItem[] = [];
const backMatterItems: GgSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatterItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: GgSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/gg/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function GgNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: GgSidebarItem | null;
next: GgSidebarItem | null;
sidebarItems: GgSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: GgSidebarItem[] = [];
const chapterItems: GgSidebarItem[] = [];
const backMatterItems: GgSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/gg/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Gītār Gān"
/>
{prev ? (
<Link
href={`/books/gg/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/gg/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getGgMeta, getGgIntroSegments } from "@/lib/gg";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("gg");
}
export default async function GgPage() {
const meta = await getGgMeta();
if (!meta) notFound();
const introSegments = await getGgIntroSegments();
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/gg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getHkhContent,
getHkhSidebarItems,
type HkhChapter,
type HkhSidebarItem,
} from "@/lib/hkh";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("hkh", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["hkh"], quote: hlQuote });
}
return ogForChapter("hkh", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function HkhChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getHkhContent(slug);
if (!content) notFound();
const sidebarItems = await getHkhSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as HkhChapter).chapter === "number" &&
(content as HkhChapter).chapter !== null;
const chapterNum = isChapter ? (content as HkhChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/hkh"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Kṛṣṇa Consciousness Handbook
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<HkhSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<HkhNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<HkhNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function HkhSidebar({
items,
currentSlug,
}: {
items: HkhSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: HkhSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/hkh/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function HkhNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: HkhSidebarItem | null;
next: HkhSidebarItem | null;
sidebarItems: HkhSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/hkh/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/hkh/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Kṛṣṇa Consciousness Handbook"
/>
{prev ? (
<Link
href={`/books/hkh/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/hkh/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getHkhMeta, getHkhIntroSegments } from "@/lib/hkh";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("hkh");
}
export default async function HkhPage() {
const meta = await getHkhMeta();
if (!meta) notFound();
const introSegments = await getHkhIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/hkh/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/hkh/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+462
View File
@@ -0,0 +1,462 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getIsoContent,
getIsoSidebarItems,
type IsoVerse,
type IsoIntroSegment,
type IsoSidebarItem,
} from "@/lib/iso";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
function isVerse(content: IsoVerse | IsoIntroSegment): content is IsoVerse {
return "mantra" in content && "devanagari" in content;
}
function isIntroSegment(
content: IsoVerse | IsoIntroSegment,
): content is IsoIntroSegment {
return "paragraphs" in content && "order" in content;
}
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ mantra: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { mantra: slug } = await params;
const sp = await searchParams;
const content = await getIsoContent(slug);
const isVerse_ = content && "mantra" in content && "devanagari" in content;
const paragraphs = content
? isVerse_
? [
(content as unknown as IsoVerse).translation,
...(content as unknown as IsoVerse).purport,
]
: (content as unknown as IsoIntroSegment).paragraphs
: undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrī Īśopaniṣad",
detail: `Mantra ${slug}`,
quote,
});
}
const title = isVerse_
? `${(content as unknown as IsoVerse).reference}`
: content && "title" in content
? (content as IsoIntroSegment).title
: `Mantra ${slug}`;
return ogForChapter("iso", title);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function IsoMantraPage({
params,
}: {
params: Promise<{ mantra: string }>;
}) {
const { mantra: slug } = await params;
const content = await getIsoContent(slug);
if (!content) notFound();
const sidebarItems = await getIsoSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title for the header
const verse = isVerse(content) ? content : null;
const intro = isIntroSegment(content) ? content : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/iso"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Īśopaniṣad
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{verse && (
<>
<span className="text-primary">{verse.reference}: </span>
{verse.bookTitle}
</>
)}
{intro && intro.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<IsoSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<IsoNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
{verse && <VerseContent verse={verse} />}
{intro && <IntroContent intro={intro} />}
{/* Bottom navigation */}
<IsoNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
// ---------------------------------------------------------------------------
function VerseContent({ verse }: { verse: IsoVerse }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
{/* Devanagari */}
<section>
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
{verse.devanagari}
</div>
</section>
{/* Transliteration */}
<section>
<div className="text-base text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
{verse.transliteration}
</div>
</section>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Synonyms
</h2>
<div className="text-base text-foreground leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
{i > 0 && "; "}
<span className="font-medium text-foreground/90">
{s.word}
</span>
{"—"}
{s.meanings.join(", ")}
</span>
))}
</div>
</section>
)}
{/* Translation */}
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Translation
</h2>
<div
className="text-base text-foreground font-medium leading-relaxed"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</section>
{/* Purport */}
{verse.purport.length > 0 && (
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Purport
</h2>
<div className="space-y-4">
{verse.purport.map((p, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: p }}
/>
))}
</div>
</section>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Intro segment content (paragraphs)
// ---------------------------------------------------------------------------
function IntroContent({ intro }: { intro: IsoIntroSegment }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{intro.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function IsoSidebar({
items,
currentSlug,
}: {
items: IsoSidebarItem[];
currentSlug: string;
}) {
// Front matter items (front-pages + introduction)
const frontMatterItems = items.filter(
(item) =>
item.slug === "intro-front-pages" || item.slug === "intro-introduction",
);
// Mantras = invocation + 1-18
const mantraItems = items.filter((item) => item.type === "verse");
// about-the-author comes last
const aboutAuthorItem = items.find(
(item) => item.slug === "intro-about-the-author",
);
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Mantras */}
{mantraItems.length > 0 && (
<>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Mantras
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{mantraItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* About the Author */}
{aboutAuthorItem && (
<nav className="flex flex-col gap-0.5">
<SidebarLink
item={aboutAuthorItem}
isActive={aboutAuthorItem.slug === currentSlug}
/>
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: IsoSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/iso/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function IsoNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: IsoSidebarItem | null;
next: IsoSidebarItem | null;
sidebarItems: IsoSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
// Front matter (front-pages + introduction)
const frontMatterItems = sidebarItems.filter(
(i) => i.slug === "intro-front-pages" || i.slug === "intro-introduction",
);
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/iso/${i.slug}`,
type: i.type,
})),
});
}
// Mantras (invocation + 1-18)
const mantraItems = sidebarItems.filter((i) => i.type === "verse");
if (mantraItems.length > 0) {
mobileNavGroups.push({
title: "Mantras",
items: mantraItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/iso/${i.slug}`,
type: i.type,
})),
});
}
// Back matter (about-the-author)
const backMatterItems = sidebarItems.filter(
(i) => i.slug === "intro-about-the-author",
);
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/iso/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Śrī Īśopaniṣad"
/>
{prev ? (
<Link
href={`/books/iso/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "verse" && prev.mantra != null && prev.mantra > 0
? `Mantra ${prev.mantra}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/iso/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "verse" && next.mantra != null && next.mantra > 0
? `Mantra ${next.mantra}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIsoMeta, getIsoIntroSegments } from "@/lib/iso";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("iso");
}
export default async function IsoPage() {
const meta = await getIsoMeta();
if (!meta) notFound();
const introSegments = await getIsoIntroSegments();
const frontPages = introSegments.find((s) => s.slug === "front-pages");
const introduction = introSegments.find((s) => s.slug === "introduction");
const aboutAuthor = introSegments.find((s) => s.slug === "about-the-author");
// Invocation is mantra 0, mantras are 1-18
const invocation = meta.mantras.find((m) => m.number === 0);
const numberedMantras = meta.mantras.filter((m) => m.number > 0);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{(frontPages || introduction) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontPages && (
<li>
<Link
href="/books/iso/intro-front-pages"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{frontPages.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{introduction && (
<li>
<Link
href="/books/iso/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{introduction.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Mantras */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Mantras
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{/* Invocation */}
{invocation && (
<li>
<Link
href="/books/iso/invocation"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Invocation
</span>
<p className="font-medium text-foreground">
{invocation.title}
</p>
</div>
</Link>
</li>
)}
{/* Mantras 1-18 */}
{numberedMantras.map((m) => (
<li key={m.number}>
<Link
href={`/books/iso/${m.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Mantra {m.number}
</span>
<p className="font-medium text-foreground">{m.title}</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* About the Author */}
{aboutAuthor && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
About the Author
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/iso/intro-about-the-author"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{aboutAuthor.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getProseChapter, getProseChapters } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ volume: string; slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { volume, slug } = await params;
const sp = await searchParams;
const vol = parseInt(volume);
const chapter = !isNaN(vol) ? await getProseChapter("kb", vol, slug) : null;
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({
title: BOOK_NAMES["kb"],
detail: chapter ? `Volume ${vol} — ${chapter.title}` : `Volume ${vol}`,
quote: hlQuote,
});
}
return ogForChapter(
"kb",
chapter ? `Volume ${vol} — ${chapter.title}` : `Volume ${vol}`,
);
}
export default async function KbChapterPage({
params,
}: {
params: Promise<{ volume: string; slug: string }>;
}) {
const { volume, slug } = await params;
const vol = parseInt(volume);
if (isNaN(vol) || vol < 1 || vol > 2) notFound();
const chapter = await getProseChapter("kb", vol, slug);
if (!chapter) notFound();
// Fetch all chapters/segments in this volume for sidebar + prev/next
const allChapters = await getProseChapters("kb", vol);
const currentIdx = allChapters.findIndex((ch) => ch.slug === slug);
const prev = currentIdx > 0 ? allChapters[currentIdx - 1] : null;
const next =
currentIdx < allChapters.length - 1 ? allChapters[currentIdx + 1] : null;
const chapterLabel =
chapter.type === "chapter" && chapter.chapter != null
? `Chapter ${chapter.chapter}`
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href={`/books/kb/${vol}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Volume {vol}
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterLabel && (
<span className="text-primary">{chapterLabel}: </span>
)}
{chapter.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar — chapter index */}
<ChapterSidebar
chapters={allChapters}
currentSlug={slug}
volume={vol}
/>
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<ChapterNav
prev={prev}
next={next}
volume={vol}
chapters={allChapters}
currentSlug={slug}
/>
{/* Chapter content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{chapter.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<ChapterNav
prev={prev}
next={next}
volume={vol}
chapters={allChapters}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar — chapter index
// ---------------------------------------------------------------------------
type SidebarChapter = {
slug: string;
title: string;
type: string;
chapter: number | null;
};
function ChapterSidebar({
chapters,
currentSlug,
volume,
}: {
chapters: SidebarChapter[];
currentSlug: string;
volume: number;
}) {
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapters.map((ch) => {
const isActive = ch.slug === currentSlug;
const label =
ch.type === "chapter" && ch.chapter != null
? `${ch.chapter}. ${ch.title}`
: ch.title;
return (
<Link
key={ch.slug}
href={`/books/kb/${volume}/${ch.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
})}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
type NavChapter = {
slug: string;
title: string;
type: string;
chapter: number | null;
};
function ChapterNav({
prev,
next,
volume,
chapters,
currentSlug,
}: {
prev: NavChapter | null;
next: NavChapter | null;
volume: number;
chapters: SidebarChapter[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = chapters.filter((i) => i.type !== "chapter");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.title,
href: `/books/kb/${volume}/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = chapters.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.chapter != null ? `${i.chapter}. ${i.title}` : i.title,
href: `/books/kb/${volume}/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle={`Kṛṣṇa Book — Volume ${volume}`}
/>
{prev ? (
<Link
href={`/books/kb/${volume}/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.title}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/kb/${volume}/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.title}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta, getProseChapters } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export async function generateMetadata({
params,
}: {
params: Promise<{ volume: string }>;
}): Promise<Metadata> {
const { volume } = await params;
const title = BOOK_NAMES["kb"] || "KB";
return buildOgMeta({ title, detail: `Volume ${volume}` });
}
export default async function KbVolumePage({
params,
}: {
params: Promise<{ volume: string }>;
}) {
const { volume } = await params;
const vol = parseInt(volume);
if (isNaN(vol) || vol < 1 || vol > 2) notFound();
const meta = await getBookMeta("kb");
if (!meta) notFound();
const title = meta.title as string;
const [introSegments, chapters] = await Promise.all([
getProseChapters("kb", vol, "intro-segment"),
getProseChapters("kb", vol, "chapter"),
]);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/kb"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {title}
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Volume {vol}
</h1>
<p className="mt-1 text-muted-foreground">{chapters.length} chapters</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/kb/${vol}/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{chapters.map((ch) => (
<li key={ch.slug}>
<Link
href={`/books/kb/${vol}/${ch.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.chapter}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
type VolumeInfo = {
number: number;
totalChapters: number;
};
export function generateMetadata(): Metadata {
return ogForBook("kb");
}
export default async function KbPage() {
const meta = await getBookMeta("kb");
if (!meta) notFound();
const title = meta.title as string;
const author = meta.author as string;
const description = meta.description as string | undefined;
const volumes = meta.volumes as VolumeInfo[];
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
<p className="mt-1 text-muted-foreground">{author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
{description && (
<p className="text-muted-foreground mb-6">{description}</p>
)}
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{volumes.map((vol) => (
<li key={vol.number}>
<Link
href={`/books/kb/${vol.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
Volume {vol.number}
</Badge>
<p className="font-medium text-foreground">
{vol.totalChapters} chapters
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+361
View File
@@ -0,0 +1,361 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getLclContent,
getLclSidebarItems,
type LclChapter,
type LclSidebarItem,
} from "@/lib/lcl";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("lcl", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["lcl"], quote: hlQuote });
}
return ogForChapter("lcl", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function LclChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getLclContent(slug);
if (!content) notFound();
const sidebarItems = await getLclSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as LclChapter).chapter === "number" &&
(content as LclChapter).chapter !== null;
const chapterNum = isChapter ? (content as LclChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/lcl"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Life Comes from Life
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<LclSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<LclNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<LclNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function LclSidebar({
items,
currentSlug,
}: {
items: LclSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters (about-the-author)
const frontMatterItems: LclSidebarItem[] = [];
const chapterItems: LclSidebarItem[] = [];
const backMatterItems: LclSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Back Matter (About the Author) */}
{backMatterItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: LclSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/lcl/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function LclNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: LclSidebarItem | null;
next: LclSidebarItem | null;
sidebarItems: LclSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: LclSidebarItem[] = [];
const chapterItems: LclSidebarItem[] = [];
const backMatterItems: LclSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lcl/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Life Comes from Life"
/>
{prev ? (
<Link
href={`/books/lcl/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/lcl/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLclMeta, getLclIntroSegments } from "@/lib/lcl";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("lcl");
}
export default async function LclPage() {
const meta = await getLclMeta();
if (!meta) notFound();
const introSegments = await getLclIntroSegments();
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/lcl/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter (About the Author) */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getLobContent,
getLobSidebarItems,
type LobChapter,
type LobSidebarItem,
} from "@/lib/lob";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("lob", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["lob"], quote: hlQuote });
}
return ogForChapter("lob", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function LobChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getLobContent(slug);
if (!content) notFound();
const sidebarItems = await getLobSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as LobChapter).chapter === "number" &&
(content as LobChapter).chapter !== null;
const chapterNum = isChapter ? (content as LobChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/lob"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Light of the Bh&#257;gavata
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Text {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<LobSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<LobNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) =>
paragraph.startsWith("<b>") ? (
<div
key={i}
className="text-base text-foreground leading-relaxed font-medium bg-muted/30 rounded-lg p-4 border border-border/50"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
) : (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
),
)}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<LobNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function LobSidebar({
items,
currentSlug,
}: {
items: LobSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Texts: chapter items
// (LoB has no back matter)
const frontMatterItems: LobSidebarItem[] = [];
const textItems: LobSidebarItem[] = [];
for (const item of items) {
if (item.type === "chapter") {
textItems.push(item);
} else {
frontMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Texts */}
{textItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Texts
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{textItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: LobSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Format: "N. Text N"
label = `${item.chapter}. Text ${item.chapter}`;
} else {
label = item.label;
}
return (
<Link
href={`/books/lob/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function LobNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: LobSidebarItem | null;
next: LobSidebarItem | null;
sidebarItems: LobSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: LobSidebarItem[] = [];
const textItems: LobSidebarItem[] = [];
for (const item of sidebarItems) {
if (item.type === "chapter") {
textItems.push(item);
} else {
frontMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lob/${i.slug}`,
type: i.type,
})),
});
}
if (textItems.length > 0) {
mobileNavGroups.push({
title: "Texts",
items: textItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/lob/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Light of the Bhāgavata"
/>
{prev ? (
<Link
href={`/books/lob/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Text ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/lob/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Text ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLobMeta, getLobIntroSegments } from "@/lib/lob";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("lob");
}
export default async function LobPage() {
const meta = await getLobMeta();
if (!meta) notFound();
const introSegments = await getLobIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lob/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Texts */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Texts
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/lob/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Text {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getMgContent,
getMgSidebarItems,
type MgChapter,
type MgSidebarItem,
} from "@/lib/mg";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("mg", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["mg"], quote: hlQuote });
}
return ogForChapter("mg", "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function MgChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getMgContent(slug);
if (!content) notFound();
const sidebarItems = await getMgSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as MgChapter).chapter === "number" &&
(content as MgChapter).chapter !== null;
const chapterNum = isChapter ? (content as MgChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Kṛṣṇa Consciousness: The Matchless Gift
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<MgSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<MgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<MgNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function MgSidebar({
items,
currentSlug,
}: {
items: MgSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: MgSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/mg/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function MgNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: MgSidebarItem | null;
next: MgSidebarItem | null;
sidebarItems: MgSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mg/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mg/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Kṛṣṇa Consciousness: The Matchless Gift"
/>
{prev ? (
<Link
href={`/books/mg/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/mg/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMgMeta, getMgIntroSegments } from "@/lib/mg";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("mg");
}
export default async function MgPage() {
const meta = await getMgMeta();
if (!meta) notFound();
const introSegments = await getMgIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/mg/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,215 @@
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import type { Metadata } from "next";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getVerseWithFallback(parseInt(chapter), parseInt(verse));
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Mukunda-mālā-stotra",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("mm", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
type MmMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("mm");
if (meta) return meta as unknown as MmMeta;
const introPath = path.join(MM_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("mm", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("mm", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `mm-${chapter}-${verse}`,
book: "mm",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const mmMeta = await getMetaWithFallback();
if (!mmMeta) notFound();
const chapterInfo = mmMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/mm/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mm"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Mukunda-mālā-stotra
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="mm"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/mm/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({ title: BOOK_NAMES["mm"], detail: `Chapter ${chapter}` });
}
export default async function MmChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(MM_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mm"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Mukunda-mālā-stotra
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("mm", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/mm/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/mm/${chapter}/${verseIndex[0].verse}`);
}
+163
View File
@@ -0,0 +1,163 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
type MmMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type MmIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
async function getMetaWithFallback(): Promise<MmMeta | null> {
const meta = await getBookMeta("mm");
if (meta) return meta as unknown as MmMeta;
const introPath = path.join(MM_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): MmIntroSegment[] {
const introDir = path.join(MM_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: MmIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as MmIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export function generateMetadata(): Metadata {
return ogForBook("mm");
}
export default async function MmPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introduction && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/mm/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mm/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getMogContent,
getMogSidebarItems,
type MogChapter,
type MogSidebarItem,
} from "@/lib/mog";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("mog", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["mog"], quote: hlQuote });
}
return ogForChapter("mog", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function MogChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getMogContent(slug);
if (!content) notFound();
const sidebarItems = await getMogSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as MogChapter).chapter === "number" &&
(content as MogChapter).chapter !== null;
const chapterNum = isChapter ? (content as MogChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Message of Godhead
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<MogSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<MogNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<MogNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function MogSidebar({
items,
currentSlug,
}: {
items: MogSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: MogSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/mog/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function MogNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: MogSidebarItem | null;
next: MogSidebarItem | null;
sidebarItems: MogSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mog/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/mog/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Message of Godhead"
/>
{prev ? (
<Link
href={`/books/mog/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/mog/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMogMeta, getMogIntroSegments } from "@/lib/mog";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("mog");
}
export default async function MogPage() {
const meta = await getMogMeta();
if (!meta) notFound();
const introSegments = await getMogIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/mog/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mog/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,220 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
type NbsMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("nbs");
if (meta) return meta as unknown as NbsMeta;
const introPath = path.join(NBS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("nbs", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("nbs", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `nbs-${chapter}-${verse}`,
book: "nbs",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"nbs",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Nārada-bhakti-sūtra",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("nbs", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const nbsMeta = await getMetaWithFallback();
if (!nbsMeta) notFound();
const chapterInfo = nbsMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/nbs/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/nbs"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Nārada-bhakti-sūtra
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="nbs"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/nbs/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import Link from "next/link";
import fs from "fs";
import path from "path";
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({
title: BOOK_NAMES["nbs"],
detail: `Chapter ${chapter}`,
});
}
export default async function NbsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(NBS_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/nbs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Nārada-bhakti-sūtra
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{segment.title}
</h1>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph: string, i: number) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Handle numeric chapters — redirect to first verse
const chapterNum = parseInt(chapter);
if (isNaN(chapterNum)) notFound();
// Try DB first
const verseIndex = await getChapterVerseIndex("nbs", null, chapterNum);
// Fallback to local JSON
if (verseIndex.length === 0) {
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0"),
"verses",
);
if (fs.existsSync(versesDir)) {
const files = fs
.readdirSync(versesDir)
.filter((f: string) => f.endsWith(".json"))
.sort();
if (files.length > 0) {
const first = JSON.parse(
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
);
redirect(`/books/nbs/${chapter}/${first.verse}`);
}
}
notFound();
}
redirect(`/books/nbs/${chapter}/${verseIndex[0].verse}`);
}
+163
View File
@@ -0,0 +1,163 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
import fs from "fs";
import path from "path";
export function generateMetadata(): Metadata {
return ogForBook("nbs");
}
type NbsMeta = {
book: string;
title: string;
author: string;
year?: number;
description?: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
type NbsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
async function getMetaWithFallback(): Promise<NbsMeta | null> {
const meta = await getBookMeta("nbs");
if (meta) return meta as unknown as NbsMeta;
const introPath = path.join(NBS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
} catch {
return null;
}
}
function getLocalIntroSegments(): NbsIntroSegment[] {
const introDir = path.join(NBS_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: NbsIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
if (fs.existsSync(filePath)) {
try {
results.push(
JSON.parse(fs.readFileSync(filePath, "utf-8")) as NbsIntroSegment,
);
} catch {
// skip
}
}
}
return results.sort((a, b) => a.order - b.order);
}
export default async function NbsPage() {
const meta = await getMetaWithFallback();
if (!meta) notFound();
const introSegments = getLocalIntroSegments();
const introduction = introSegments.find((s) => s.slug === "introduction");
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introduction && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/nbs/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/nbs/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+326
View File
@@ -0,0 +1,326 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getNodContent,
getNodSidebarItems,
type NodChapter,
type NodSidebarItem,
} from "@/lib/nod";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("nod", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["nod"], quote: hlQuote });
}
return ogForChapter("nod", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function NodChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getNodContent(slug);
if (!content) notFound();
const sidebarItems = await getNodSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as NodChapter).chapter === "number" &&
(content as NodChapter).chapter !== null;
const chapterNum = isChapter ? (content as NodChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/nod"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Nectar of Devotion
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<NodSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<NodNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<NodNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function NodSidebar({
items,
currentSlug,
}: {
items: NodSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
const conclusionItems = items.filter((item) => item.type === "conclusion");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Concluding Words */}
{conclusionItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{conclusionItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: NodSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/nod/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function NodNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: NodSidebarItem | null;
next: NodSidebarItem | null;
sidebarItems: NodSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/nod/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
const conclusionItems = sidebarItems.filter((i) => i.type === "conclusion");
if (chapterItems.length > 0 || conclusionItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: [
...chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/nod/${i.slug}`,
type: i.type,
})),
...conclusionItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/nod/${i.slug}`,
type: i.type,
})),
],
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Nectar of Devotion"
/>
{prev ? (
<Link
href={`/books/nod/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/nod/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getNodMeta, getNodIntroSegments } from "@/lib/nod";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("nod");
}
const PART_LABELS: Record<number, string> = {
1: "Part One",
2: "Part Two",
3: "Part Three",
4: "Part Four",
};
export default async function NodPage() {
const meta = await getNodMeta();
if (!meta) notFound();
const introSegments = await getNodIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/nod/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts & Chapters */}
{meta.parts.map((part) => {
const partChapters = part.chapters
.map((num) => meta.chapters.find((ch) => ch.number === num))
.filter(Boolean) as { number: number; title: string }[];
return (
<div key={part.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{PART_LABELS[part.number] ?? `Part ${part.number}`}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{partChapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/nod/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
})}
{/* Concluding Words */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Conclusion
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/nod/concluding-words"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
Concluding Words
</p>
</div>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+451
View File
@@ -0,0 +1,451 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getNoiContent,
getNoiSidebarItems,
type NoiVerse,
type NoiIntroSegment,
type NoiSidebarItem,
} from "@/lib/noi";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
function isVerse(content: NoiVerse | NoiIntroSegment): content is NoiVerse {
return "text" in content && "devanagari" in content;
}
function isIntroSegment(
content: NoiVerse | NoiIntroSegment,
): content is NoiIntroSegment {
return "paragraphs" in content && "order" in content;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ text: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { text: slug } = await params;
const sp = await searchParams;
const content = await getNoiContent(slug);
let paragraphs: string[] | undefined;
if (content) {
if ("devanagari" in content) {
// It's a verse
paragraphs = [content.translation, ...content.purport];
} else if ("paragraphs" in content) {
// It's an intro segment
paragraphs = content.paragraphs;
}
}
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "The Nectar of Instruction",
detail: `Text ${slug}`,
quote,
});
}
return ogForChapter("noi", `Text ${slug}`);
}
export default async function NoiTextPage({
params,
}: {
params: Promise<{ text: string }>;
}) {
const { text: slug } = await params;
const content = await getNoiContent(slug);
if (!content) notFound();
const sidebarItems = await getNoiSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title for the header
const verse = isVerse(content) ? content : null;
const intro = isIntroSegment(content) ? content : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/noi"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Nectar of Instruction
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{verse && (
<>
<span className="text-primary">Text {verse.text}: </span>
{verse.title}
</>
)}
{intro && intro.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<NoiSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<NoiNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
{verse && <VerseContent verse={verse} />}
{intro && <IntroContent intro={intro} />}
{/* Bottom navigation */}
<NoiNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
// ---------------------------------------------------------------------------
function VerseContent({ verse }: { verse: NoiVerse }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
{/* Devanagari */}
<section>
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
{verse.devanagari}
</div>
</section>
{/* Transliteration */}
<section>
<div className="text-base text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
{verse.transliteration}
</div>
</section>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Synonyms
</h2>
<div className="text-base text-foreground leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
{i > 0 && "; "}
<span className="font-medium text-foreground/90">
{s.word}
</span>
{"—"}
{s.meanings.join(", ")}
</span>
))}
</div>
</section>
)}
{/* Translation */}
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Translation
</h2>
<div
className="text-base text-foreground font-medium leading-relaxed"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</section>
{/* Purport */}
{verse.purport.length > 0 && (
<section>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Purport
</h2>
<div className="space-y-4">
{verse.purport.map((p, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: p }}
/>
))}
</div>
</section>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Intro segment content (paragraphs)
// ---------------------------------------------------------------------------
function IntroContent({ intro }: { intro: NoiIntroSegment }) {
return (
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{intro.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function NoiSidebar({
items,
currentSlug,
}: {
items: NoiSidebarItem[];
currentSlug: string;
}) {
// Front matter items (front-pages + preface)
const frontMatterItems = items.filter(
(item) =>
item.slug === "intro-front-pages" || item.slug === "intro-preface",
);
// Texts = 1-11
const textItems = items.filter((item) => item.type === "verse");
// Appendixes comes last
const appendixesItem = items.find((item) => item.slug === "intro-appendixes");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Texts */}
{textItems.length > 0 && (
<>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Texts
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{textItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Appendixes */}
{appendixesItem && (
<nav className="flex flex-col gap-0.5">
<SidebarLink
item={appendixesItem}
isActive={appendixesItem.slug === currentSlug}
/>
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: NoiSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/noi/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function NoiNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: NoiSidebarItem | null;
next: NoiSidebarItem | null;
sidebarItems: NoiSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
// Front matter (front-pages + preface)
const frontMatterItems = sidebarItems.filter(
(i) => i.slug === "intro-front-pages" || i.slug === "intro-preface",
);
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/noi/${i.slug}`,
type: i.type,
})),
});
}
// Texts (1-11)
const textItems = sidebarItems.filter((i) => i.type === "verse");
if (textItems.length > 0) {
mobileNavGroups.push({
title: "Texts",
items: textItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/noi/${i.slug}`,
type: i.type,
})),
});
}
// Back matter (appendixes)
const backMatterItems = sidebarItems.filter(
(i) => i.slug === "intro-appendixes",
);
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/noi/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Nectar of Instruction"
/>
{prev ? (
<Link
href={`/books/noi/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "verse" && prev.text != null
? `Text ${prev.text}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/noi/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "verse" && next.text != null
? `Text ${next.text}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getNoiMeta, getNoiIntroSegments } from "@/lib/noi";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("noi");
}
export default async function NoiPage() {
const meta = await getNoiMeta();
if (!meta) notFound();
const introSegments = await getNoiIntroSegments();
const frontPages = introSegments.find((s) => s.slug === "front-pages");
const preface = introSegments.find((s) => s.slug === "preface");
const appendixes = introSegments.find((s) => s.slug === "appendixes");
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 italic text-muted-foreground">{meta.subtitle}</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{(frontPages || preface) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontPages && (
<li>
<Link
href="/books/noi/intro-front-pages"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{frontPages.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{preface && (
<li>
<Link
href="/books/noi/intro-preface"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{preface.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Texts */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Texts
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.texts.map((t) => (
<li key={t.number}>
<Link
href={`/books/noi/${t.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Text {t.number}
</span>
<p className="font-medium text-foreground">{t.title}</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Appendixes */}
{appendixes && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Appendixes
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/noi/intro-appendixes"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{appendixes.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getOchkmContent,
getOchkmSidebarItems,
type OchkmChapter,
type OchkmSidebarItem,
} from "@/lib/ochkm";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ochkm", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ochkm"], quote: hlQuote });
}
return ogForChapter("ochkm", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function OchkmChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getOchkmContent(slug);
if (!content) notFound();
const sidebarItems = await getOchkmSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as OchkmChapter).chapter === "number" &&
(content as OchkmChapter).chapter !== null;
const chapterNum = isChapter ? (content as OchkmChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ochkm"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; On Chanting the Hare Kṛṣṇa Mantra
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<OchkmSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<OchkmNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<OchkmNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function OchkmSidebar({
items,
currentSlug,
}: {
items: OchkmSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: OchkmSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ochkm/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function OchkmNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: OchkmSidebarItem | null;
next: OchkmSidebarItem | null;
sidebarItems: OchkmSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ochkm/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ochkm/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="On Chanting the Hare Kṛṣṇa Mantra"
/>
{prev ? (
<Link
href={`/books/ochkm/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ochkm/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getOchkmMeta, getOchkmIntroSegments } from "@/lib/ochkm";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ochkm");
}
export default async function OchkmPage() {
const meta = await getOchkmMeta();
if (!meta) notFound();
const introSegments = await getOchkmIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ochkm/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ochkm/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getOwkContent,
getOwkSidebarItems,
type OwkChapter,
type OwkSidebarItem,
} from "@/lib/owk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("owk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["owk"], quote: hlQuote });
}
return ogForChapter("owk", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function OwkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getOwkContent(slug);
if (!content) notFound();
const sidebarItems = await getOwkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as OwkChapter).chapter === "number" &&
(content as OwkChapter).chapter !== null;
const chapterNum = isChapter ? (content as OwkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/owk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; On the Way to Kṛṣṇa
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<OwkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<OwkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<OwkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function OwkSidebar({
items,
currentSlug,
}: {
items: OwkSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: OwkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/owk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function OwkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: OwkSidebarItem | null;
next: OwkSidebarItem | null;
sidebarItems: OwkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/owk/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/owk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="On the Way to Kṛṣṇa"
/>
{prev ? (
<Link
href={`/books/owk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/owk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getOwkMeta, getOwkIntroSegments } from "@/lib/owk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("owk");
}
export default async function OwkPage() {
const meta = await getOwkMeta();
if (!meta) notFound();
const introSegments = await getOwkIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/owk/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/owk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { getAllBooks, type BookMeta } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BooksGrid, type BookInfo } from "./books-grid";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Books",
detail: "Books by Śrīla Prabhupāda",
quote:
"Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, and 30+ other books with Devanagari, transliteration, synonyms, translations & purports.",
});
}
type ChapterEntry = { totalVerses: number };
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
type LilaEntry = { totalChapters: number; chapters: ChapterEntry[] };
interface ChapterBookMeta extends BookMeta {
totalChapters: number;
chapters: ChapterEntry[];
}
interface SbMeta extends BookMeta {
totalCantos: number;
cantos: CantoEntry[];
}
interface CcMeta extends BookMeta {
totalLilas: number;
lilas: LilaEntry[];
}
function buildBookList(allBooks: BookMeta[]): BookInfo[] {
const books: BookInfo[] = [];
for (const data of allBooks) {
const slug = data.book;
const title = data.title as string;
const author = data.author as string;
if (slug === "bg") {
const bg = data as ChapterBookMeta;
const totalVerses = bg.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
books.push({
slug,
title,
author,
description: `${bg.totalChapters} chapters`,
contentDescription:
"The essence of Vedic knowledge — Lord Kṛṣṇa's teachings to Arjuna on the battlefield of Kurukṣetra.",
totalVerses,
href: "/books/bg",
});
} else if (slug === "sb1962") {
const sb62 = data as ChapterBookMeta;
const totalVerses = sb62.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
books.push({
slug,
title,
author,
description: `${sb62.totalChapters} chapters · First Canto`,
contentDescription:
(data as { description?: string }).description ||
"The original 1962 Indian edition of the First Canto.",
totalVerses,
href: "/books/sb1962",
});
} else if (slug === "sb") {
const sb = data as SbMeta;
const totalChapters = sb.cantos.reduce(
(sum, c) => sum + c.totalChapters,
0,
);
const totalVerses = sb.cantos.reduce(
(sum, c) => sum + c.chapters.reduce((s, ch) => s + ch.totalVerses, 0),
0,
);
books.push({
slug,
title,
author,
description: `${sb.totalCantos} cantos, ${totalChapters} chapters`,
contentDescription:
"The ripened fruit of Vedic literature — the science of God in 18,000 verses.",
totalVerses,
href: "/books/sb",
});
} else if (slug === "cc") {
const cc = data as CcMeta;
const totalChapters = cc.lilas.reduce(
(sum, l) => sum + l.totalChapters,
0,
);
const totalVerses = cc.lilas.reduce(
(sum, l) => sum + l.chapters.reduce((s, ch) => s + ch.totalVerses, 0),
0,
);
books.push({
slug,
title,
author,
description: `${cc.totalLilas} līlās, ${totalChapters} chapters`,
contentDescription:
"The life and teachings of Lord Caitanya Mahāprabhu — the most complete theistic science.",
totalVerses,
href: "/books/cc",
});
} else {
// Generic handler for all other books — pulls description from metadata
const metaDesc = (data as { description?: string }).description || "";
const totalChapters = (data as { totalChapters?: number }).totalChapters;
const totalMantras = (data as { totalMantras?: number }).totalMantras;
const totalTexts = (data as { totalTexts?: number }).totalTexts;
const parts = (data as { parts?: unknown[] }).parts;
const volumes = (data as { volumes?: unknown[] }).volumes;
let badge = "";
if (totalMantras) badge = `${totalMantras} mantras`;
else if (totalTexts) badge = `${totalTexts} texts`;
else if (totalChapters && volumes)
badge = `${totalChapters} chapters · ${volumes.length} volumes`;
else if (totalChapters && parts)
badge = `${totalChapters} chapters · ${parts.length} parts`;
else if (totalChapters) badge = `${totalChapters} chapters`;
books.push({
slug,
title,
author,
description: badge,
contentDescription: metaDesc,
totalVerses: 0,
href: `/books/${slug}`,
});
}
}
return books;
}
export default async function BooksPage() {
const allBooks = await getAllBooks();
const books = buildBookList(allBooks);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Books</h1>
<p className="mt-1 text-muted-foreground">
Browse the books and teachings of Śrīla Prabhupāda
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<BooksGrid books={books} />
</main>
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPkContent,
getPkSidebarItems,
type PkChapter,
type PkSidebarItem,
} from "@/lib/pk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("pk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["pk"], quote: hlQuote });
}
return ogForChapter("pk", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPkContent(slug);
if (!content) notFound();
const sidebarItems = await getPkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as PkChapter).chapter;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/pk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Prayers of King Kulaśekhara
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Verse {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<PkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PkSidebar({
items,
currentSlug,
}: {
items: PkSidebarItem[];
currentSlug: string;
}) {
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Chapters (Prayers) */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Prayers
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: PkSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/pk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function PkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: PkSidebarItem | null;
next: PkSidebarItem | null;
sidebarItems: PkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Prayers",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Prayers of King Kulaśekhara"
/>
{prev ? (
<Link
href={`/books/pk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.chapter != null ? `Prayer ${prev.chapter}` : prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/pk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.chapter != null ? `Prayer ${next.chapter}` : next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPkMeta } from "@/lib/pk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("pk");
}
export default async function PkPage() {
const meta = await getPkMeta();
if (!meta) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters (Prayers) */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Prayers
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/pk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Verse {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPoyContent,
getPoySidebarItems,
type PoyChapter,
type PoySidebarItem,
} from "@/lib/poy";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("poy", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["poy"], quote: hlQuote });
}
return ogForChapter("poy", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PoyChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPoyContent(slug);
if (!content) notFound();
const sidebarItems = await getPoySidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as PoyChapter).chapter === "number" &&
(content as PoyChapter).chapter !== null;
const chapterNum = isChapter ? (content as PoyChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/poy"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Perfection of Yoga
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<PoySidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PoyNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PoyNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PoySidebar({
items,
currentSlug,
}: {
items: PoySidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: PoySidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/poy/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function PoyNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: PoySidebarItem | null;
next: PoySidebarItem | null;
sidebarItems: PoySidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/poy/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/poy/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Perfection of Yoga"
/>
{prev ? (
<Link
href={`/books/poy/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/poy/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPoyMeta, getPoyIntroSegments } from "@/lib/poy";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("poy");
}
export default async function PoyPage() {
const meta = await getPoyMeta();
if (!meta) notFound();
const introSegments = await getPoyIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/poy/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/poy/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+391
View File
@@ -0,0 +1,391 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getPqpaContent,
getPqpaSidebarItems,
type PqpaChapter,
type PqpaSidebarItem,
} from "@/lib/pqpa";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("pqpa", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["pqpa"], quote: hlQuote });
}
return ogForChapter("pqpa", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function PqpaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getPqpaContent(slug);
if (!content) notFound();
const sidebarItems = await getPqpaSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as PqpaChapter).chapter === "number" &&
(content as PqpaChapter).chapter !== null;
const chapterNum = isChapter ? (content as PqpaChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/pqpa"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Perfect Questions Perfect Answers
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<PqpaSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<PqpaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<PqpaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function PqpaSidebar({
items,
currentSlug,
}: {
items: PqpaSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Conclusion: conclusion items
// Back matter: intro-segment items after conclusion (about-the-author)
const frontMatterItems: PqpaSidebarItem[] = [];
const chapterItems: PqpaSidebarItem[] = [];
const conclusionItems: PqpaSidebarItem[] = [];
const backMatterItems: PqpaSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (item.type === "conclusion") {
pastChapters = true;
conclusionItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Concluding Words */}
{conclusionItems.length > 0 && (
<nav className="flex flex-col gap-0.5 mb-4">
{conclusionItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
{/* About the Author */}
{backMatterItems.length > 0 && (
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: PqpaSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/pqpa/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function PqpaNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: PqpaSidebarItem | null;
next: PqpaSidebarItem | null;
sidebarItems: PqpaSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: PqpaSidebarItem[] = [];
const chapterItems: PqpaSidebarItem[] = [];
const conclusionItems: PqpaSidebarItem[] = [];
const backMatterItems: PqpaSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (item.type === "conclusion") {
pastChapters = true;
conclusionItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pqpa/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: [
...chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pqpa/${i.slug}`,
type: i.type,
})),
...conclusionItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pqpa/${i.slug}`,
type: i.type,
})),
],
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/pqpa/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Perfect Questions Perfect Answers"
/>
{prev ? (
<Link
href={`/books/pqpa/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/pqpa/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPqpaMeta, getPqpaIntroSegments } from "@/lib/pqpa";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("pqpa");
}
export default async function PqpaPage() {
const meta = await getPqpaMeta();
if (!meta) notFound();
const introSegments = await getPqpaIntroSegments();
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/pqpa/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/pqpa/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Concluding Words */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Conclusion
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/pqpa/concluding-words"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
Concluding Words
</p>
</div>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
{/* About the Author */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/pqpa/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getRvContent,
getRvSidebarItems,
type RvChapter,
type RvSidebarItem,
} from "@/lib/rv";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("rv", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["rv"], quote: hlQuote });
}
return ogForChapter("rv", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function RvChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getRvContent(slug);
if (!content) notFound();
const sidebarItems = await getRvSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as RvChapter).chapter === "number" &&
(content as RvChapter).chapter !== null;
const chapterNum = isChapter ? (content as RvChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/rv"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Rāja-vidyā: The King of Knowledge
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<RvSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<RvNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<RvNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function RvSidebar({
items,
currentSlug,
}: {
items: RvSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: RvSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/rv/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function RvNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: RvSidebarItem | null;
next: RvSidebarItem | null;
sidebarItems: RvSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/rv/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/rv/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Rāja-vidyā: The King of Knowledge"
/>
{prev ? (
<Link
href={`/books/rv/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/rv/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getRvMeta, getRvIntroSegments } from "@/lib/rv";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("rv");
}
export default async function RvPage() {
const meta = await getRvMeta();
if (!meta) notFound();
const introSegments = await getRvIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/rv/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/rv/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,135 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ canto: string; chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { canto, chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"sb",
canto,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrīmad-Bhāgavatam",
detail: `Canto ${canto}, Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("sb", {
division: canto,
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function SbVersePage({
params,
}: {
params: Promise<{ canto: string; chapter: string; verse: string }>;
}) {
const { canto, chapter, verse } = await params;
const meta = await getBookMeta("sb");
if (!meta) notFound();
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
const cantoInfo = cantos.find((c) => c.number === parseInt(canto));
if (!cantoInfo) notFound();
const chapterInfo = cantoInfo.chapters.find(
(ch) => ch.number === parseInt(chapter),
);
if (!chapterInfo) notFound();
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verse);
const verseData = await getSingleVerse(
"sb",
canto,
parseInt(chapter),
requestedVerse,
);
if (!verseData) notFound();
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${canto}.${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${canto}.${chapter}.${v.verse}`,
href: `/books/sb/${canto}/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href={`/books/sb/${canto}`}
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Canto {cantoInfo.number}: {cantoInfo.title}
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {chapterInfo.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verseData}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="sb"
division={canto}
chapter={parseInt(chapter)}
showBengali={false}
basePath={`/books/sb/${canto}/${chapter}`}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
/>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
export async function generateMetadata({
params,
}: {
params: Promise<{ canto: string; chapter: string }>;
}): Promise<Metadata> {
const { canto, chapter } = await params;
return buildOgMeta({
title: "Śrīmad-Bhāgavatam",
detail: `Canto ${canto}, Chapter ${chapter}`,
});
}
export default async function SbChapterPage({
params,
}: {
params: Promise<{ canto: string; chapter: string }>;
}) {
const { canto, chapter } = await params;
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/sb/${canto}/${chapter}/${verseIndex[0].verse}`);
}
+98
View File
@@ -0,0 +1,98 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../../extracted-verse-count";
import { getBookMeta } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
export async function generateMetadata({
params,
}: {
params: Promise<{ canto: string }>;
}): Promise<Metadata> {
const { canto } = await params;
const title = BOOK_NAMES["sb"] || "Śrīmad-Bhāgavatam";
return buildOgMeta({ title, detail: `Canto ${canto}` });
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export default async function CantoPage({
params,
}: {
params: Promise<{ canto: string }>;
}) {
const { canto } = await params;
const cantoNum = parseInt(canto);
const meta = await getBookMeta("sb");
if (!meta) notFound();
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
const cantoInfo = cantos.find((c) => c.number === cantoNum);
if (!cantoInfo) notFound();
const totalVerses = cantoInfo.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/sb"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrīmad-Bhāgavatam
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Canto {cantoInfo.number}: {cantoInfo.title}
</h1>
<p className="mt-1 text-muted-foreground">
{cantoInfo.totalChapters} chapters ·{" "}
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{cantoInfo.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/sb/${cantoNum}/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">{ch.title}</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ExtractedVerseCount } from "../extracted-verse-count";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { notFound } from "next/navigation";
export function generateMetadata(): Metadata {
return ogForBook("sb");
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export default async function SbPage() {
const meta = await getBookMeta("sb");
if (!meta) notFound();
const intro = meta as {
book: string;
title: string;
author: string;
totalCantos: number;
cantos: CantoInfo[];
};
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{intro.title}
</h1>
<p className="mt-1 text-muted-foreground">{intro.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{intro.cantos.map((canto) => {
const totalVerses = canto.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<li key={canto.number}>
<Link
href={`/books/sb/${canto.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
Canto {canto.number}
</Badge>
<p className="font-medium text-foreground">
{canto.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{canto.totalChapters} chapters ·{" "}
<ExtractedVerseCount
count={totalVerses}
label="extracted verses"
/>
</span>
</Link>
</li>
);
})}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
@@ -0,0 +1,132 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
type Sb1962Meta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"sb1962",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrīmad-Bhāgavatam (1962)",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("sb1962", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const meta = await getBookMeta("sb1962");
if (!meta) notFound();
const sb1962Meta = meta as unknown as Sb1962Meta;
const chapterInfo = sb1962Meta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getChapterVerseIndex(
"sb1962",
null,
parseInt(chapter),
);
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getSingleVerse(
"sb1962",
null,
parseInt(chapter),
requestedVerse,
);
if (!verse) notFound();
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/sb1962/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/sb1962"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrīmad-Bhāgavatam (1962)
</Link>
<h1 className="mt-2 text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="sb1962"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/sb1962/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
/>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({
title: BOOK_NAMES["sb1962"],
detail: `Chapter ${chapter}`,
});
}
export default async function ChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
const verseIndex = await getChapterVerseIndex(
"sb1962",
null,
parseInt(chapter),
);
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/sb1962/${chapter}/${verseIndex[0].verse}`);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
export function generateMetadata(): Metadata {
return ogForBook("sb1962");
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
export default async function Sb1962Page() {
const meta = await getBookMeta("sb1962");
if (!meta) notFound();
const intro = meta as unknown as {
book: string;
title: string;
author: string;
description?: string;
totalChapters: number;
chapters: ChapterInfo[];
};
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{intro.title}
</h1>
<p className="mt-1 text-muted-foreground">{intro.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{intro.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/sb1962/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">{ch.title}</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getSsrContent,
getSsrSidebarItems,
type SsrChapter,
type SsrSidebarItem,
} from "@/lib/ssr";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ssr", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ssr"], quote: hlQuote });
}
return ogForChapter("ssr", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function SsrChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getSsrContent(slug);
if (!content) notFound();
const sidebarItems = await getSsrSidebarItems();
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
const next =
currentIdx < navigableItems.length - 1
? navigableItems[currentIdx + 1]
: null;
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
const isSubChapter =
"parentChapter" in content &&
typeof (content as SsrChapter).parentChapter === "number";
const parentChapterNum = isSubChapter
? (content as SsrChapter).parentChapter
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ssr"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Science of Self-Realization
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Chapter {parentChapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<SsrSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<SsrNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<SsrNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function SsrSidebar({
items,
currentSlug,
}: {
items: SsrSidebarItem[];
currentSlug: string;
}) {
// Split items by section
const frontMatter = items.filter(
(i) => i.type === "intro-segment" && i.slug !== "intro-about-the-author",
);
const parents = items.filter((i) => i.type === "parent");
const subChapters = items.filter((i) => i.type === "sub-chapter");
const backMatter = items.filter((i) => i.slug === "intro-about-the-author");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatter.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatter.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters — grouped by parent */}
{parents.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{parents.map((parent) => (
<div key={parent.slug}>
{/* Parent heading — not a link, just a bold label */}
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
{parent.label}
</div>
{/* Sub-chapters indented */}
{subChapters
.filter((sub) => sub.parentChapter === parent.parentChapter)
.map((sub) => {
const isActive = sub.slug === currentSlug;
return (
<Link
key={sub.slug}
href={`/books/ssr/${sub.slug}`}
className={cn(
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={sub.label}
>
{sub.label}
</Link>
);
})}
</div>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatter.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Back Matter
</h2>
<nav className="flex flex-col gap-0.5">
{backMatter.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: SsrSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ssr/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function SsrNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: SsrSidebarItem | null;
next: SsrSidebarItem | null;
sidebarItems: SsrSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatter = sidebarItems.filter(
(i) => i.type === "intro-segment" && i.slug !== "intro-about-the-author",
);
if (frontMatter.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatter.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ssr/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter(
(i) => i.type === "parent" || i.type === "sub-chapter",
);
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ssr/${i.slug}`,
type: i.type,
parentChapter: i.parentChapter,
isParent: i.type === "parent",
})),
});
}
const backMatter = sidebarItems.filter(
(i) => i.slug === "intro-about-the-author",
);
if (backMatter.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatter.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ssr/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="The Science of Self-Realization"
/>
{prev ? (
<Link
href={`/books/ssr/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ssr/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getSsrMeta, getSsrIntroSegments } from "@/lib/ssr";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ssr");
}
export default async function SsrPage() {
const meta = await getSsrMeta();
if (!meta) notFound();
const introSegments = await getSsrIntroSegments();
// Split intro segments into front matter (before chapters) and back matter (after)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ssr/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters — hierarchical parent → sub-chapter structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{ch.number}. {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/ssr/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
{/* Back Matter (About the Author) */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ssr/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getSvaContent,
getSvaSidebarItems,
getNavigableItems,
type SvaChapter,
type SvaSidebarItem,
} from "@/lib/sva";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("sva", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["sva"], quote: hlQuote });
}
return ogForChapter("sva", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function SvaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getSvaContent(slug);
if (!content) notFound();
const sidebarItems = await getSvaSidebarItems();
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
const navigableItems = getNavigableItems(sidebarItems);
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
const next =
currentIdx < navigableItems.length - 1
? navigableItems[currentIdx + 1]
: null;
// Determine if this is a sub-chapter (has parentChapter)
const isSubChapter =
"parentChapter" in content &&
typeof (content as SvaChapter).parentChapter === "number";
const parentChapterNum = isSubChapter
? (content as SvaChapter).parentChapter
: null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/sva"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Songs of the Vaiṣṇava Ācāryas
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Part {parentChapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<SvaSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<SvaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<SvaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function SvaSidebar({
items,
currentSlug,
}: {
items: SvaSidebarItem[];
currentSlug: string;
}) {
// Split items by section
const frontMatter = items.filter((i) => i.type === "intro-segment");
const parents = items.filter((i) => i.type === "parent");
const subChapters = items.filter((i) => i.type === "sub-chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatter.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatter.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Parts — grouped by parent */}
{parents.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Songs
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{parents.map((parent) => (
<div key={parent.slug}>
{/* Parent heading — not a link, just a bold label */}
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
{parent.label}
</div>
{/* Sub-chapters (songs) indented */}
{subChapters
.filter((sub) => sub.parentChapter === parent.parentChapter)
.map((sub) => {
const isActive = sub.slug === currentSlug;
return (
<Link
key={sub.slug}
href={`/books/sva/${sub.slug}`}
className={cn(
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={sub.label}
>
{sub.label}
</Link>
);
})}
</div>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: SvaSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/sva/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function SvaNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: SvaSidebarItem | null;
next: SvaSidebarItem | null;
sidebarItems: SvaSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatter = sidebarItems.filter((i) => i.type === "intro-segment");
if (frontMatter.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatter.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/sva/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter(
(i) => i.type === "parent" || i.type === "sub-chapter",
);
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Songs",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/sva/${i.slug}`,
type: i.type,
parentChapter: i.parentChapter,
isParent: i.type === "parent",
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Songs of the Vaiṣṇava Ācāryas"
/>
{prev ? (
<Link
href={`/books/sva/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/sva/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getSvaMeta, getSvaIntroSegments } from "@/lib/sva";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("sva");
}
export default async function SvaPage() {
const meta = await getSvaMeta();
if (!meta) notFound();
const introSegments = await getSvaIntroSegments();
// Split intro segments into front matter (before chapters) and back matter (after)
const frontMatter = introSegments.filter((seg) => seg.order < 99);
const backMatter = introSegments.filter((seg) => seg.order >= 99);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts — hierarchical parent → song (sub-chapter) structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Part {ch.number}: {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/sva/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+345
View File
@@ -0,0 +1,345 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTlcContent,
getTlcSidebarItems,
type TlcChapter,
type TlcSidebarItem,
} from "@/lib/tlc";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("tlc", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["tlc"], quote: hlQuote });
}
return ogForChapter("tlc", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TlcChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTlcContent(slug);
if (!content) notFound();
const sidebarItems = await getTlcSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as TlcChapter).chapter === "number" &&
(content as TlcChapter).chapter !== null;
const chapterNum = isChapter ? (content as TlcChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/tlc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Lord Caitanya
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<TlcSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TlcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TlcNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TlcSidebar({
items,
currentSlug,
}: {
items: TlcSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
// Group chapters by part (if parts exist)
const hasParts = chapterItems.some((item) => item.partNumber != null);
const partGroups: {
partNumber: number;
partTitle: string;
chapters: TlcSidebarItem[];
}[] = [];
if (hasParts) {
const partMap = new Map<
number,
{ partTitle: string; chapters: TlcSidebarItem[] }
>();
for (const item of chapterItems) {
const pn = item.partNumber ?? 0;
const pt = item.partTitle ?? `Part ${pn}`;
if (!partMap.has(pn)) partMap.set(pn, { partTitle: pt, chapters: [] });
partMap.get(pn)!.chapters.push(item);
}
for (const [partNumber, { partTitle, chapters }] of partMap) {
partGroups.push({ partNumber, partTitle, chapters });
}
partGroups.sort((a, b) => a.partNumber - b.partNumber);
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters — grouped by part if available */}
{hasParts
? partGroups.map((group) => (
<div key={group.partNumber} className="mb-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
{group.partTitle}
</h2>
<nav className="flex flex-col gap-0.5">
{group.chapters.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</div>
))
: chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TlcSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/tlc/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function TlcNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TlcSidebarItem | null;
next: TlcSidebarItem | null;
sidebarItems: TlcSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlc/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlc/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Teachings of Lord Caitanya"
/>
{prev ? (
<Link
href={`/books/tlc/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/tlc/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTlcMeta, getTlcIntroSegments } from "@/lib/tlc";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("tlc");
}
const PART_LABELS: Record<number, string> = {
1: "Part I",
2: "Part II",
};
export default async function TlcPage() {
const meta = await getTlcMeta();
if (!meta) notFound();
const introSegments = await getTlcIntroSegments();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlc/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts & Chapters */}
{meta.parts.map((part) => {
const partChapters = part.chapters
.map((num) => meta.chapters.find((ch) => ch.number === num))
.filter(Boolean) as { number: number; title: string }[];
return (
<div key={part.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{PART_LABELS[part.number] ?? `Part ${part.number}`}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{partChapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tlc/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
})}
</main>
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTlkContent,
getTlkSidebarItems,
type TlkChapter,
type TlkSidebarItem,
} from "@/lib/tlk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("tlk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["tlk"], quote: hlQuote });
}
return ogForChapter("tlk", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TlkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTlkContent(slug);
if (!content) notFound();
const sidebarItems = await getTlkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as TlkChapter).chapter === "number" &&
(content as TlkChapter).chapter !== null;
const chapterNum = isChapter ? (content as TlkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/tlk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Lord Kapila
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<TlkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TlkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TlkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TlkSidebar({
items,
currentSlug,
}: {
items: TlkSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: TlkSidebarItem[] = [];
const chapterItems: TlkSidebarItem[] = [];
const backMatterItems: TlkSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Back Matter
</h2>
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TlkSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/tlk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function TlkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TlkSidebarItem | null;
next: TlkSidebarItem | null;
sidebarItems: TlkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: TlkSidebarItem[] = [];
const chapterItems: TlkSidebarItem[] = [];
const backMatterItems: TlkSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tlk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Teachings of Lord Kapila"
/>
{prev ? (
<Link
href={`/books/tlk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/tlk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTlkMeta, getTlkIntroSegments } from "@/lib/tlk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("tlk");
}
export default async function TlkPage() {
const meta = await getTlkMeta();
if (!meta) notFound();
const introSegments = await getTlkIntroSegments();
// Split intro segments into front matter (order < 50) and back matter (order >= 50)
const frontMatter = introSegments.filter((seg) => seg.order < 50);
const backMatter = introSegments.filter((seg) => seg.order >= 50);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tlk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTqkContent,
getTqkSidebarItems,
type TqkChapter,
type TqkSidebarItem,
} from "@/lib/tqk";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("tqk", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["tqk"], quote: hlQuote });
}
return ogForChapter("tqk", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TqkChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTqkContent(slug);
if (!content) notFound();
const sidebarItems = await getTqkSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as TqkChapter).chapter === "number" &&
(content as TqkChapter).chapter !== null;
const chapterNum = isChapter ? (content as TqkChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/tqk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Queen Kuntī
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<TqkSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TqkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TqkNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TqkSidebar({
items,
currentSlug,
}: {
items: TqkSidebarItem[];
currentSlug: string;
}) {
// Split items into groups for visual sections
// Front matter: intro-segment items before chapters
// Chapters: chapter items
// Back matter: intro-segment items after chapters
const frontMatterItems: TqkSidebarItem[] = [];
const chapterItems: TqkSidebarItem[] = [];
const backMatterItems: TqkSidebarItem[] = [];
let pastChapters = false;
for (const item of items) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{frontMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{frontMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Back Matter */}
{backMatterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Back Matter
</h2>
<nav className="flex flex-col gap-0.5">
{backMatterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TqkSidebarItem;
isActive: boolean;
}) {
const label = item.label;
return (
<Link
href={`/books/tqk/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={label}
>
{label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function TqkNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TqkSidebarItem | null;
next: TqkSidebarItem | null;
sidebarItems: TqkSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const frontMatterItems: TqkSidebarItem[] = [];
const chapterItems: TqkSidebarItem[] = [];
const backMatterItems: TqkSidebarItem[] = [];
let pastChapters = false;
for (const item of sidebarItems) {
if (item.type === "chapter") {
pastChapters = true;
chapterItems.push(item);
} else if (!pastChapters) {
frontMatterItems.push(item);
} else {
backMatterItems.push(item);
}
}
if (frontMatterItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: frontMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
if (backMatterItems.length > 0) {
mobileNavGroups.push({
title: "Back Matter",
items: backMatterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/tqk/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Teachings of Queen Kuntī"
/>
{prev ? (
<Link
href={`/books/tqk/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/tqk/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTqkMeta, getTqkIntroSegments } from "@/lib/tqk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("tqk");
}
export default async function TqkPage() {
const meta = await getTqkMeta();
if (!meta) notFound();
const introSegments = await getTqkIntroSegments();
// Split intro segments into front matter (order < 50) and back matter (order >= 50)
const frontMatter = introSegments.filter((seg) => seg.order < 50);
const backMatter = introSegments.filter((seg) => seg.order >= 50);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tqk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTtpContent,
getTtpSidebarItems,
type TtpChapter,
type TtpSidebarItem,
} from "@/lib/ttp";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("ttp", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["ttp"], quote: hlQuote });
}
return ogForChapter("ttp", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function TtpChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getTtpContent(slug);
if (!content) notFound();
const sidebarItems = await getTtpSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
// Build the page title
const isChapter =
"chapter" in content &&
typeof (content as TtpChapter).chapter === "number" &&
(content as TtpChapter).chapter !== null;
const chapterNum = isChapter ? (content as TtpChapter).chapter : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/ttp"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Transcendental Teachings of Prahlāda Mahārāja
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<TtpSidebar items={sidebarItems} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<TtpNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<TtpNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function TtpSidebar({
items,
currentSlug,
}: {
items: TtpSidebarItem[];
currentSlug: string;
}) {
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
{introItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Front Matter
</h2>
<nav className="flex flex-col gap-0.5 mb-4">
{introItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
function SidebarLink({
item,
isActive,
}: {
item: TtpSidebarItem;
isActive: boolean;
}) {
return (
<Link
href={`/books/ttp/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function TtpNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: TtpSidebarItem | null;
next: TtpSidebarItem | null;
sidebarItems: TtpSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
if (introItems.length > 0) {
mobileNavGroups.push({
title: "Front Matter",
items: introItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ttp/${i.slug}`,
type: i.type,
})),
});
}
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
if (chapterItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: chapterItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/ttp/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Transcendental Teachings of Prahlāda Mahārāja"
/>
{prev ? (
<Link
href={`/books/ttp/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.type === "chapter" && prev.chapter != null
? `Ch. ${prev.chapter}`
: prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/ttp/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.type === "chapter" && next.chapter != null
? `Ch. ${next.chapter}`
: next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTtpMeta } from "@/lib/ttp";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("ttp");
}
export default async function TtpPage() {
const meta = await getTtpMeta();
if (!meta) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ttp/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getVaContent,
getVaSidebarItems,
type VaChapter,
type VaSidebarItem,
} from "@/lib/va";
import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter: slug } = await params;
const sp = await searchParams;
const chapter = await getProseChapter("va", 0, slug);
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
if (hlQuote) {
return buildOgMeta({ title: BOOK_NAMES["va"], quote: hlQuote });
}
return ogForChapter("va", chapter?.title || "");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function VaChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter: slug } = await params;
const content = await getVaContent(slug);
if (!content) notFound();
const sidebarItems = await getVaSidebarItems();
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as VaChapter).chapter;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/va"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Vaiśiṣṭyāṣṭakam
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{content.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<VaSidebar items={sidebarItems} currentSlug={slug} />
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
<VaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{content.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
<VaNav
prev={prev}
next={next}
sidebarItems={sidebarItems}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
function VaSidebar({
items,
currentSlug,
}: {
items: VaSidebarItem[];
currentSlug: string;
}) {
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{items.map((item) => (
<Link
key={item.slug}
href={`/books/va/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
item.slug === currentSlug
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.label}
>
{item.label}
</Link>
))}
</nav>
</aside>
);
}
function VaNav({
prev,
next,
sidebarItems,
currentSlug,
}: {
prev: VaSidebarItem | null;
next: VaSidebarItem | null;
sidebarItems: VaSidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
if (sidebarItems.length > 0) {
mobileNavGroups.push({
title: "Chapters",
items: sidebarItems.map((i) => ({
slug: i.slug,
label: i.label,
href: `/books/va/${i.slug}`,
type: i.type,
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Vaiśiṣṭyāṣṭakam"
/>
{prev ? (
<Link
href={`/books/va/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/books/va/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getVaMeta } from "@/lib/va";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
export function generateMetadata(): Metadata {
return ogForBook("va");
}
export default async function VaPage() {
const meta = await getVaMeta();
if (!meta) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/va/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
/**
* Public collection at /c/[id]. Anyone can view if `is_public=true`.
* Private collections return 403 from the BFF, which we treat as "not
* found" to avoid leaking existence.
*/
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Globe, Lock, FolderPlus } from "lucide-react";
import {
collections,
SocialError,
type Collection,
type Post,
} from "@/lib/social";
import { PostViews } from "@/components/social/post-views";
export default function PublicCollectionPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = use(props.params);
const [c, setC] = useState<Collection | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [missing, setMissing] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
if (cancelled) return;
setC(collection);
setPosts(posts ?? []);
})
.catch((err) => {
if (
err instanceof SocialError &&
(err.status === 404 || err.status === 403)
) {
setMissing(true);
}
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [id]);
if (missing) notFound();
if (loading || !c)
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 py-12">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6 py-10 space-y-8">
<header className="rounded-2xl bg-card ring-1 ring-foreground/10 p-6 sm:p-8">
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{c.is_public ? <Globe className="size-3.5" /> : <Lock className="size-3.5" />}
{c.is_public ? "Public collection" : "Private collection"}
</div>
<h1 className="mt-3 font-[family-name:var(--font-title)] font-semibold text-3xl sm:text-4xl text-foreground leading-tight">
{c.title}
</h1>
{c.description ? (
<p className="mt-3 text-base text-muted-foreground leading-relaxed">
{c.description}
</p>
) : null}
<p className="mt-4 text-xs text-muted-foreground tabular-nums">
{c.item_count} post{c.item_count === 1 ? "" : "s"}
</p>
</header>
<section className="space-y-3">
{posts.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<FolderPlus className="size-6 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground mt-2">
This collection has no items yet.
</p>
</div>
) : (
<PostViews posts={posts} />
)}
</section>
</main>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
/**
* /chat — placeholder for the Kautilya AI study companion.
*
* The actual chat surface (an AI tuned on Śrīla Prabhupāda's books,
* letters, and lectures) isn't built yet. Until it ships we render a
* coming-soon page so the nav entries (sidebar + bottom bar) have a
* real route to point at. When the feature lands, swap this file for
* the real chat UI — nav wiring stays the same.
*/
import Link from "next/link";
import { Compass } from "lucide-react";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
export default function ChatPage() {
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24">
<div className="flex flex-col items-center text-center gap-5">
<span
aria-hidden
className="inline-flex size-14 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-7" />
</span>
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight text-balance">
Kautilya is coming soon
</h1>
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
An AI study companion tuned on Śrīla Prabhupāda&apos;s books,
letters, and lectures — ask any question, get answers grounded in
his own words with citations back to the source passages. Until
it lands, the full library is already waiting.
</p>
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
<Link
href="/search"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-5 h-11 text-sm font-medium hover:bg-primary/90 transition-colors min-w-[12rem]"
>
<Compass className="size-4" />
Explore Knowledgebase
</Link>
</div>
</div>
</main>
);
}
+588
View File
@@ -0,0 +1,588 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
Search,
ArrowUpDown,
ArrowDownAZ,
ArrowUpAZ,
Calendar,
CalendarArrowDown,
MapPin,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DiscipleSummary = {
id: string;
initiatedName: string;
suffix: string;
initiationDate: string | null;
initiationPlace: string | null;
isVerified: boolean;
};
export type LetterCount = { letter: string; count: number };
export type PlaceGroup = { name: string; count: number };
export type YearGroup = { year: number; count: number };
export type SuffixCount = { suffix: string; count: number };
type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
type Props = {
initialDisciples: DiscipleSummary[];
initialLetter: string;
activeSuffix: SuffixFilter;
letterCounts: LetterCount[];
suffixCounts: SuffixCount[];
places: PlaceGroup[];
years: YearGroup[];
totalCount: number;
filteredCount: number;
};
type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXY".split("");
const PAGE_SIZE = 60;
const SUFFIX_TABS: { key: SuffixFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "Dasa", label: "Dasa" },
{ key: "Devi Dasi", label: "Devi Dasi" },
];
// ---------------------------------------------------------------------------
// Diacritic-aware normalize
// ---------------------------------------------------------------------------
function normalize(text: string): string {
return text
.toLowerCase()
.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")
.replace(/ā/g, "a")
.replace(/ī/g, "i")
.replace(/ū/g, "u")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extractYear(isoDate: string | null): number | null {
if (!isoDate) return null;
const year = parseInt(isoDate.slice(0, 4), 10);
return isNaN(year) ? null : year;
}
function formatLocationYear(
place: string | null,
date: string | null,
): string | null {
const year = extractYear(date);
const parts: string[] = [];
if (place) parts.push(place);
if (year) parts.push(String(year));
return parts.length > 0 ? parts.join(" · ") : null;
}
/**
* Build the href for letter + suffix navigation.
* Always preserves both params so the URL stays consistent.
*/
function buildHref(letter: string, suffix: SuffixFilter): string {
const params = new URLSearchParams();
params.set("letter", letter);
if (suffix !== "all") {
params.set("suffix", suffix);
}
return `/disciples?${params.toString()}`;
}
// ---------------------------------------------------------------------------
// Sort options
// ---------------------------------------------------------------------------
const SORT_OPTIONS: {
key: SortMode;
label: string;
icon: typeof ArrowDownAZ;
}[] = [
{ key: "name-asc", label: "Name A → Z", icon: ArrowDownAZ },
{ key: "name-desc", label: "Name Z → A", icon: ArrowUpAZ },
{ key: "date-asc", label: "Earliest first", icon: Calendar },
{ key: "date-desc", label: "Latest first", icon: CalendarArrowDown },
{ key: "place", label: "By place", icon: MapPin },
];
function sortDisciples(
data: DiscipleSummary[],
mode: SortMode,
): DiscipleSummary[] {
const sorted = [...data];
switch (mode) {
case "name-asc":
return sorted.sort((a, b) =>
a.initiatedName.localeCompare(b.initiatedName),
);
case "name-desc":
return sorted.sort((a, b) =>
b.initiatedName.localeCompare(a.initiatedName),
);
case "date-asc":
return sorted.sort((a, b) => {
if (!a.initiationDate && !b.initiationDate) return 0;
if (!a.initiationDate) return 1;
if (!b.initiationDate) return -1;
return a.initiationDate.localeCompare(b.initiationDate);
});
case "date-desc":
return sorted.sort((a, b) => {
if (!a.initiationDate && !b.initiationDate) return 0;
if (!a.initiationDate) return 1;
if (!b.initiationDate) return -1;
return b.initiationDate.localeCompare(a.initiationDate);
});
case "place":
return sorted.sort((a, b) => {
if (!a.initiationPlace && !b.initiationPlace) return 0;
if (!a.initiationPlace) return 1;
if (!b.initiationPlace) return -1;
const cmp = a.initiationPlace.localeCompare(b.initiationPlace);
return cmp !== 0 ? cmp : a.initiatedName.localeCompare(b.initiatedName);
});
}
}
// ---------------------------------------------------------------------------
// Sort Popover
// ---------------------------------------------------------------------------
function SortPopover({
sort,
onSortChange,
}: {
sort: SortMode;
onSortChange: (s: SortMode) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className={cn(
"flex items-center justify-center size-10 rounded-lg border transition-colors",
open
? "bg-primary/10 border-primary/30 text-primary"
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title="Sort options"
aria-label="Sort options"
>
<ArrowUpDown className="size-4" />
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-52 rounded-lg border border-border bg-card shadow-lg py-1 animate-in fade-in-0 zoom-in-95">
{SORT_OPTIONS.map((opt) => {
const Icon = opt.icon;
const isActive = sort === opt.key;
return (
<button
key={opt.key}
onClick={() => {
onSortChange(opt.key);
setOpen(false);
}}
className={cn(
"flex items-center gap-3 w-full px-3 py-2.5 text-sm transition-colors text-left",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-foreground hover:bg-muted",
)}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1">{opt.label}</span>
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center gap-3 pt-4">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
page <= 1
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronLeft className="size-4" />
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
page >= totalPages
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
Next
<ChevronRight className="size-4" />
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DisciplesBrowser({
initialDisciples,
initialLetter,
activeSuffix,
letterCounts,
suffixCounts,
totalCount,
filteredCount,
}: Props) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("name-asc");
const [page, setPage] = useState(1);
// Reset page when search changes
const handleQueryChange = (q: string) => {
setQuery(q);
setPage(1);
};
const handleSortChange = (s: SortMode) => {
setSort(s);
setPage(1);
};
// Build a lookup map for letter counts
const countByLetter = useMemo(() => {
const map = new Map<string, number>();
for (const lc of letterCounts) {
map.set(lc.letter.toUpperCase(), lc.count);
}
return map;
}, [letterCounts]);
// Build a lookup map for suffix counts
const countBySuffix = useMemo(() => {
const map = new Map<string, number>();
let total = 0;
for (const sc of suffixCounts) {
map.set(sc.suffix, sc.count);
total += sc.count;
}
map.set("all", total);
return map;
}, [suffixCounts]);
// Client-side search filtering + sorting
const filtered = useMemo(() => {
let data = initialDisciples;
if (query.trim()) {
const q = normalize(query.trim());
data = data.filter((d) => normalize(d.initiatedName).includes(q));
}
return sortDisciples(data, sort);
}, [initialDisciples, query, sort]);
// Pagination
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const startIdx = (safePage - 1) * PAGE_SIZE;
const pageData = filtered.slice(startIdx, startIdx + PAGE_SIZE);
// Total for letter display
const totalLetterCount = Array.from(countByLetter.values()).reduce(
(a, b) => a + b,
0,
);
return (
<div className="space-y-5">
{/* ---- Suffix Filter Tabs ---- */}
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-1 w-fit">
{SUFFIX_TABS.map((tab) => {
const count = countBySuffix.get(tab.key) ?? 0;
const isActive = activeSuffix === tab.key;
return (
<Link
key={tab.key}
href={buildHref(initialLetter, tab.key)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
<span
className={cn(
"text-xs tabular-nums",
isActive ? "text-foreground/60" : "text-muted-foreground/60",
)}
>
{count.toLocaleString()}
</span>
</Link>
);
})}
</div>
{/* ---- Letter Navigation Bar ---- */}
<nav className="flex flex-wrap gap-1 justify-center">
{/* "All" button */}
<Link
href={buildHref("All", activeSuffix)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg px-3 py-1.5 text-sm font-medium transition-colors min-w-9",
initialLetter === "All"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="leading-none">All</span>
<span
className={cn(
"text-[10px] leading-none mt-0.5",
initialLetter === "All"
? "text-primary-foreground/70"
: "text-muted-foreground/60",
)}
>
{totalLetterCount.toLocaleString()}
</span>
</Link>
{/* Divider */}
<div className="w-px bg-border self-stretch mx-0.5" />
{/* Letter buttons */}
{LETTERS.map((letter) => {
const count = countByLetter.get(letter) ?? 0;
const isActive =
letter.toUpperCase() === initialLetter.toUpperCase() &&
initialLetter !== "All";
const isEmpty = count === 0;
return (
<Link
key={letter}
href={buildHref(letter, activeSuffix)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors min-w-9",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: isEmpty
? "text-muted-foreground/40 cursor-default"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="leading-none">{letter}</span>
<span
className={cn(
"text-[10px] leading-none mt-0.5",
isActive
? "text-primary-foreground/70"
: "text-muted-foreground/60",
)}
>
{count}
</span>
</Link>
);
})}
</nav>
{/* ---- Search Bar + Sort ---- */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search initiated names..."
value={query}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleQueryChange(e.target.value)
}
className="pl-9"
/>
</div>
<SortPopover sort={sort} onSortChange={handleSortChange} />
</div>
{/* ---- Summary Stats ---- */}
<p className="text-sm text-muted-foreground">
{query.trim() ? (
<>
Found{" "}
<span className="font-medium text-foreground">
{filtered.length}
</span>{" "}
of{" "}
<span className="font-medium text-foreground">
{initialDisciples.length}
</span>{" "}
disciples
</>
) : (
<>
Showing{" "}
<span className="font-medium text-foreground">
{filteredCount.toLocaleString()}
</span>{" "}
{filteredCount === 1 ? "disciple" : "disciples"}
{filteredCount !== totalCount && (
<>
{" "}
of{" "}
<span className="font-medium text-foreground">
{totalCount.toLocaleString()}
</span>{" "}
total
</>
)}
</>
)}
</p>
{/* ---- Results Grid ---- */}
{pageData.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">No disciples found</p>
<p className="text-sm mt-1">
{query.trim()
? "Try a different search term"
: `No disciples found for the current filter`}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{pageData.map((disciple) => {
const locationYear = formatLocationYear(
disciple.initiationPlace,
disciple.initiationDate,
);
return (
<Card
key={disciple.id}
size="sm"
className="transition-all hover:shadow-md hover:ring-foreground/20"
>
<CardContent className="py-3 px-4 space-y-1.5">
<div className="flex items-start justify-between gap-2">
<p className="font-semibold text-foreground leading-snug truncate">
{disciple.initiatedName}
</p>
{disciple.suffix && (
<Badge
variant="secondary"
className="shrink-0 text-[10px]"
>
{disciple.suffix}
</Badge>
)}
</div>
{locationYear && (
<p className="text-xs text-muted-foreground truncate">
{locationYear}
</p>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* ---- Pagination ---- */}
<Pagination
page={safePage}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { ogForDisciples } from "@/lib/og";
import {
getTotalDiscipleCount,
getFilteredDisciples,
getDiscipleLetterCounts,
getDisciplePlaces,
getDiscipleYears,
getDiscipleSuffixCounts,
getFilteredDiscipleCount,
} from "@/lib/disciples";
import type { SuffixFilter } from "@/lib/disciples";
import { DisciplesBrowser } from "./disciples-browser";
export async function generateMetadata(): Promise<Metadata> {
return ogForDisciples();
}
const VALID_SUFFIXES: SuffixFilter[] = ["all", "Dasa", "Devi Dasi"];
function parseSuffix(raw: string | undefined): SuffixFilter {
if (!raw) return "all";
// Handle URL-encoded "Devi Dasi" → "Devi+Dasi" or "Devi%20Dasi"
const decoded = decodeURIComponent(raw.replace(/\+/g, " "));
if (VALID_SUFFIXES.includes(decoded as SuffixFilter)) {
return decoded as SuffixFilter;
}
return "all";
}
function parseLetter(raw: string | undefined): string {
if (!raw) return "A";
const cleaned = raw.trim();
if (cleaned.toLowerCase() === "all") return "All";
// Single uppercase letter
const ch = cleaned.charAt(0).toUpperCase();
if (ch >= "A" && ch <= "Z") return ch;
return "A";
}
export default async function DisciplesPage({
searchParams,
}: {
searchParams: Promise<{ letter?: string; suffix?: string }>;
}) {
const params = await searchParams;
const activeLetter = parseLetter(params.letter);
const activeSuffix = parseSuffix(params.suffix);
// letter=null means "All" — no letter filter
const letterFilter = activeLetter === "All" ? null : activeLetter;
const [
totalCount,
disciples,
filteredCount,
letterCounts,
places,
years,
suffixCounts,
] = await Promise.all([
getTotalDiscipleCount(),
getFilteredDisciples(letterFilter, activeSuffix),
getFilteredDiscipleCount(letterFilter, activeSuffix),
getDiscipleLetterCounts(),
getDisciplePlaces(),
getDiscipleYears(),
getDiscipleSuffixCounts(),
]);
// Serialize dates to ISO strings for the client component
const serializedDisciples = disciples.map((d) => ({
...d,
initiationDate: d.initiationDate ? d.initiationDate.toISOString() : null,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Disciples</h1>
<p className="mt-1 text-muted-foreground">
{totalCount.toLocaleString()} disciples initiated by Śrīla Prabhupāda
(1966–1977)
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<DisciplesBrowser
initialDisciples={serializedDisciples}
initialLetter={activeLetter}
activeSuffix={activeSuffix}
letterCounts={letterCounts}
suffixCounts={suffixCounts}
places={places}
years={years}
totalCount={totalCount}
filteredCount={filteredCount}
/>
</main>
</div>
);
}
+288
View File
@@ -0,0 +1,288 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ogForEssay, 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";
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// HTML sanitisation (same pattern used across all readers)
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
type EssayData = {
slug: string;
title: string;
author: string;
year: number | null;
group: string | null;
paragraphs: string[];
};
type SidebarItem = {
slug: string;
title: string;
};
async function getEssay(slug: string): Promise<EssayData | null> {
const doc = await bff<{
slug: string;
title: string;
author: string;
year: number | null;
group: string | null;
paragraphs: string[];
}>(`/essays/${slug}`);
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
author: doc.author,
year: doc.year,
group: doc.group,
paragraphs: (doc.paragraphs ?? []).map(sanitizeHtml),
};
}
async function getEssaySidebar(): Promise<SidebarItem[]> {
return bffList<SidebarItem>("/essays");
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { slug } = await params;
const sp = await searchParams;
const essay = await getEssay(slug);
if (!essay) return {};
const hlQuote = extractQuoteFromHl(sp, essay.paragraphs);
if (hlQuote) {
return buildOgMeta({
title: essay.title,
detail: "Essay by Śrīla Prabhupāda",
quote: hlQuote,
});
}
return ogForEssay(essay.title);
}
export default async function EssayPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const essay = await getEssay(slug);
if (!essay) notFound();
const allEssays = await getEssaySidebar();
const currentIdx = allEssays.findIndex((item) => item.slug === slug);
const prev = currentIdx > 0 ? allEssays[currentIdx - 1] : null;
const next =
currentIdx < allEssays.length - 1 ? allEssays[currentIdx + 1] : null;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/essays"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Essays
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{essay.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<EssaySidebar items={allEssays} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EssayNav
prev={prev}
next={next}
allEssays={allEssays}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{essay.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EssayNav
prev={prev}
next={next}
allEssays={allEssays}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EssaySidebar({
items,
currentSlug,
}: {
items: SidebarItem[];
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">
Essays
</h2>
<nav className="flex flex-col gap-0.5">
{items.map((item) => (
<Link
key={item.slug}
href={`/essays/${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.title}
>
{item.title}
</Link>
))}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function EssayNav({
prev,
next,
allEssays,
currentSlug,
}: {
prev: SidebarItem | null;
next: SidebarItem | null;
allEssays: SidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
if (allEssays.length > 0) {
mobileNavGroups.push({
title: "Essays",
items: allEssays.map((i) => ({
slug: i.slug,
label: i.title,
href: `/essays/${i.slug}`,
type: "chapter",
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Essays"
/>
{prev ? (
<Link
href={`/essays/${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.title}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/essays/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.title}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
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";
export type EssayInfo = {
slug: string;
title: string;
year: number | null;
group: string | null;
href: string;
};
/**
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
*/
function normalize(text: string): string {
return (
text
.toLowerCase()
.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")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
);
}
/** Human-readable group labels */
const GROUP_LABELS: Record<string, string> = {
"cc-essays": "Śrī Caitanya-caritāmṛta Essays",
"pre-1977-essays": "Pre-1977 Essays",
"two-essays": "Two Essays (1966)",
reviews: "Reviews",
standalone: "Standalone",
"typed-transcripts": "Typed Transcripts",
};
function groupLabel(group: string | null): string {
if (!group) return "Other";
return GROUP_LABELS[group] ?? group;
}
export function EssaysGrid({ essays }: { essays: EssayInfo[] }) {
const [query, setQuery] = useState("");
const normalizedEssays = useMemo(
() =>
essays.map((essay) => ({
essay,
searchText: normalize(
`${essay.title} ${essay.group ?? ""} ${essay.slug}`,
),
})),
[essays],
);
const filtered = useMemo(() => {
if (!query.trim()) return essays;
const q = normalize(query.trim());
return normalizedEssays
.filter((entry) => entry.searchText.includes(q))
.map((entry) => entry.essay);
}, [essays, normalizedEssays, 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 essays by title..."
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 essays found"
: `${filtered.length} essay${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((essay) => (
<Link key={essay.slug} href={essay.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">
{essay.title}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{essay.year && (
<Badge variant="secondary" className="text-xs">
{essay.year}
</Badge>
)}
{essay.group && (
<span className="text-muted-foreground text-xs">
{groupLabel(essay.group)}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og";
import { bffList } from "@/lib/bff";
import { EssaysGrid } from "./essays-grid";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Essays",
detail: "Standalone essays by Śrīla Prabhupāda",
quote:
"Foundational essays on the science of Kṛṣṇa consciousness by His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda.",
});
}
async function getAllEssays() {
return bffList<{
slug: string;
title: string;
year: number | null;
group: string | null;
}>("/essays");
}
export default async function EssaysPage() {
const essays = await getAllEssays();
const items = essays.map((essay) => ({
slug: essay.slug,
title: essay.title,
year: essay.year,
group: essay.group,
href: `/essays/${essay.slug}`,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Essays</h1>
<p className="mt-1 text-muted-foreground">
Standalone essays by Śrīla Prabhupāda
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<EssaysGrid essays={items} />
</main>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
/**
* /feedback — single-page form for bug reports, feature requests, and
* general comments. Submissions persist to the BFF and trigger an
* admin notification email. No follow-up UI beyond a success toast
* and a redirect back to /.
*
* Authed visitors don't see the contact-email field — the BFF reads
* their account email from the session cookie. Anonymous visitors get
* the field and must fill it in so we have a reply path.
*/
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, MessageSquare, Send } from "lucide-react";
import { toast } from "sonner";
import { feedback as feedbackAPI, SocialError } from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
type Category = "bug" | "feature" | "general" | "other";
const CATEGORIES: { value: Category; label: string; description: string }[] = [
{ value: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
{ value: "bug", label: "Bug report", description: "Something is broken or behaving unexpectedly." },
{ value: "feature", label: "Feature request", description: "An idea for something new on the site." },
{ value: "other", label: "Other", description: "Doesn't fit the above? Use this." },
];
export default function FeedbackPage() {
const router = useRouter();
const { user, loading } = useAuth();
const authed = !loading && !!user;
const [category, setCategory] = useState<Category>("general");
const [message, setMessage] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const trimmed = message.trim();
if (!trimmed) {
setError("Message is required.");
return;
}
if (!authed && !contactEmail.trim()) {
setError("Email is required so we can reply.");
return;
}
setSubmitting(true);
try {
await feedbackAPI.submit({
category,
message: trimmed,
contact_email: authed ? undefined : contactEmail.trim(),
});
toast.success("Thanks for the feedback!", {
description: "We've forwarded it to the team.",
duration: 5000,
});
setMessage("");
setContactEmail("");
router.push("/");
} catch (err) {
setError(err instanceof SocialError ? err.message : "Could not send feedback.");
} finally {
setSubmitting(false);
}
}
const selected = CATEGORIES.find((c) => c.value === category);
return (
<main className="flex-1 max-w-xl w-full mx-auto px-4 sm:px-6 py-10 sm:py-14">
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" /> Back to home
</Link>
<header className="mt-6 mb-6 flex items-start gap-3">
<span
aria-hidden
className="inline-flex size-11 items-center justify-center rounded-full bg-primary/10 text-primary shrink-0"
>
<MessageSquare className="size-5" />
</span>
<div>
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl text-foreground leading-tight">
Send feedback
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Tell us what&apos;s working, what&apos;s broken, or what you&apos;d like to see next. We read every message.
</p>
</div>
</header>
<form
onSubmit={submit}
className="space-y-5 rounded-2xl bg-card ring-1 ring-foreground/10 p-5"
>
<div>
<label htmlFor="category" className="text-sm font-medium text-foreground">
What&apos;s this about?
</label>
<select
id="category"
value={category}
onChange={(e) => setCategory(e.target.value as Category)}
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
disabled={submitting}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<p className="mt-1 text-xs text-muted-foreground">
{selected?.description}
</p>
</div>
<div>
<label htmlFor="message" className="text-sm font-medium text-foreground">
Message
</label>
<textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Share as much detail as you'd like…"
rows={6}
maxLength={5000}
disabled={submitting}
className="mt-1.5 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
/>
<p className="mt-1 text-xs text-muted-foreground text-right tabular-nums">
{message.length}/5000
</p>
</div>
{!loading && !authed ? (
<div>
<label htmlFor="contact_email" className="text-sm font-medium text-foreground">
Your email
</label>
<input
id="contact_email"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={submitting}
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="mt-1 text-xs text-muted-foreground">
So we can write back if needed. We don&apos;t add you to a mailing list.
</p>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<button
type="submit"
disabled={submitting || !message.trim()}
className="w-full h-11 inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground text-base font-medium hover:bg-primary/90 transition-colors disabled:opacity-60"
>
<Send className="size-4" />
{submitting ? "Sending…" : "Send feedback"}
</button>
</form>
</main>
);
}

Some files were not shown because too many files have changed in this diff Show More