feat: add policy pages
This commit is contained in:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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'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'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'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'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' 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getBbdContent,
|
||||
getBbdSidebarItems,
|
||||
type BbdChapter,
|
||||
type BbdSidebarItem,
|
||||
} from "@/lib/bbd";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("bbd", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["bbd"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("bbd", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function BbdChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getBbdContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getBbdSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as BbdChapter).chapter === "number" &&
|
||||
(content as BbdChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as BbdChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/bbd"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Beyond Birth and Death
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<BbdSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<BbdNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<BbdNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BbdSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: BbdSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: BbdSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/bbd/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BbdNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: BbdSidebarItem | null;
|
||||
next: BbdSidebarItem | null;
|
||||
sidebarItems: BbdSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/bbd/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/bbd/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Beyond Birth and Death"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/bbd/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/bbd/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBbdMeta, getBbdIntroSegments } from "@/lib/bbd";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("bbd");
|
||||
}
|
||||
|
||||
export default async function BbdPage() {
|
||||
const meta = await getBbdMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getBbdIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/bbd/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/bbd/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter, verse } = await params;
|
||||
const sp = await searchParams;
|
||||
const v = await getSingleVerse(
|
||||
"bg",
|
||||
null,
|
||||
parseInt(chapter),
|
||||
parseInt(verse),
|
||||
);
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: "Bhagavad-gītā As It Is",
|
||||
detail: `Chapter ${chapter}, Verse ${verse}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForVerse("bg", {
|
||||
chapter: parseInt(chapter),
|
||||
verse: parseInt(verse),
|
||||
});
|
||||
}
|
||||
|
||||
type BgMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
export default async function VersePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
}) {
|
||||
const { chapter, verse: verseParam } = await params;
|
||||
|
||||
const meta = await getBookMeta("bg");
|
||||
if (!meta) notFound();
|
||||
|
||||
const bgMeta = meta as unknown as BgMeta;
|
||||
const chapterInfo = bgMeta.chapters?.find(
|
||||
(c) => c.number === parseInt(chapter),
|
||||
);
|
||||
if (!chapterInfo?.title) notFound();
|
||||
|
||||
const title: string = chapterInfo.title;
|
||||
|
||||
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
|
||||
if (verseIndex.length === 0) notFound();
|
||||
|
||||
const requestedVerse = parseInt(verseParam);
|
||||
const verse = await getSingleVerse(
|
||||
"bg",
|
||||
null,
|
||||
parseInt(chapter),
|
||||
requestedVerse,
|
||||
);
|
||||
if (!verse) notFound();
|
||||
|
||||
// Build mobile nav groups — verses for the current chapter
|
||||
const mobileNavGroups: MobileNavGroup[] = [
|
||||
{
|
||||
title: "Verses",
|
||||
items: verseIndex.map((v) => ({
|
||||
slug: String(v.verse),
|
||||
label: v.verseEnd
|
||||
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
|
||||
: `Verse ${chapter}.${v.verse}`,
|
||||
href: `/books/bg/${chapter}/${v.verse}`,
|
||||
type: "verse",
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/bg"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Bhagavad-gītā
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
||||
Chapter {chapter}: {title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
<ChapterViewer
|
||||
initialVerse={verse}
|
||||
initialVerseNum={requestedVerse}
|
||||
verseIndex={verseIndex.map((v) => ({
|
||||
verse: v.verse,
|
||||
verseEnd: v.verseEnd ?? undefined,
|
||||
reference: v.reference,
|
||||
}))}
|
||||
book="bg"
|
||||
division={null}
|
||||
chapter={parseInt(chapter)}
|
||||
basePath={`/books/bg/${chapter}`}
|
||||
showBengali={false}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getChapterVerseIndex } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter } = await params;
|
||||
return buildOgMeta({ title: BOOK_NAMES["bg"], detail: `Chapter ${chapter}` });
|
||||
}
|
||||
|
||||
export default async function ChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter } = await params;
|
||||
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
|
||||
if (verseIndex.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
redirect(`/books/bg/${chapter}/${verseIndex[0].verse}`);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("bg");
|
||||
}
|
||||
|
||||
type ChapterInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalVerses: number;
|
||||
};
|
||||
|
||||
export default async function BgPage() {
|
||||
const meta = await getBookMeta("bg");
|
||||
if (!meta) notFound();
|
||||
|
||||
const intro = meta as unknown as {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
totalChapters: number;
|
||||
chapters: ChapterInfo[];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{intro.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{intro.author}</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card className="divide-y divide-border py-0">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{intro.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/bg/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">{ch.title}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
<ExtractedVerseCount count={ch.totalVerses} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getBkContent,
|
||||
getBkSidebarItems,
|
||||
type BkChapter,
|
||||
type BkSidebarItem,
|
||||
} from "@/lib/bk";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("bk", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["bk"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("bk", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function BkChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getBkContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getBkSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const chapterNum = (content as BkChapter).chapter ?? null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/bk"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Bhagavāner Kathā
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<BkSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<BkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<BkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BkSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: BkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: BkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/bk/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BkNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: BkSidebarItem | null;
|
||||
next: BkSidebarItem | null;
|
||||
sidebarItems: BkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/bk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Bhagavāner Kathā"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/bk/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/bk/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBkMeta } from "@/lib/bk";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("bk");
|
||||
}
|
||||
|
||||
export default async function BkPage() {
|
||||
const meta = await getBkMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/bk/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Search } from "lucide-react";
|
||||
import { ExtractedVerseCount } from "./extracted-verse-count";
|
||||
|
||||
export type BookInfo = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
description: string;
|
||||
contentDescription: string;
|
||||
totalVerses: number;
|
||||
href: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
|
||||
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
|
||||
*
|
||||
* Uses Unicode NFD decomposition to split base characters from
|
||||
* combining marks, then removes the marks. Also handles special
|
||||
* IAST characters that don't decompose cleanly via NFD.
|
||||
*/
|
||||
function normalize(text: string): string {
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
// Handle IAST characters that NFD doesn't decompose into ASCII base
|
||||
.replace(/ṛ/g, "r")
|
||||
.replace(/ṝ/g, "r")
|
||||
.replace(/ṣ/g, "s")
|
||||
.replace(/ś/g, "s")
|
||||
.replace(/ṭ/g, "t")
|
||||
.replace(/ḍ/g, "d")
|
||||
.replace(/ṇ/g, "n")
|
||||
.replace(/ñ/g, "n")
|
||||
.replace(/ṅ/g, "n")
|
||||
.replace(/ḥ/g, "h")
|
||||
.replace(/ṁ/g, "m")
|
||||
.replace(/ṃ/g, "m")
|
||||
.replace(/ḷ/g, "l")
|
||||
// NFD decompose + strip combining marks (handles ā→a, ī→i, ū→u, etc.)
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
export function BooksGrid({ books }: { books: BookInfo[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalizedBooks = useMemo(
|
||||
() =>
|
||||
books.map((book) => ({
|
||||
book,
|
||||
searchText: normalize(
|
||||
`${book.title} ${book.contentDescription} ${book.description} ${book.slug}`,
|
||||
),
|
||||
})),
|
||||
[books],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return books;
|
||||
const q = normalize(query.trim());
|
||||
return normalizedBooks
|
||||
.filter((entry) => entry.searchText.includes(q))
|
||||
.map((entry) => entry.book);
|
||||
}, [books, normalizedBooks, query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search books by name..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results count when searching */}
|
||||
{query.trim() && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filtered.length === 0
|
||||
? "No books found"
|
||||
: `${filtered.length} book${filtered.length === 1 ? "" : "s"} found`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((book) => (
|
||||
<Link key={book.slug} href={book.href} className="block h-full">
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full transition-all hover:shadow-md hover:ring-foreground/20",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-bold leading-snug">
|
||||
{book.title}
|
||||
</CardTitle>
|
||||
{book.contentDescription && (
|
||||
<CardDescription className="text-xs line-clamp-2">
|
||||
{book.contentDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{book.description && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{book.description}
|
||||
</Badge>
|
||||
)}
|
||||
{book.totalVerses > 0 && (
|
||||
<ExtractedVerseCount
|
||||
count={book.totalVerses}
|
||||
label="verses"
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Metadata } from "next";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getBookMeta,
|
||||
getSingleVerse,
|
||||
getChapterVerseIndex,
|
||||
type VerseIndexEntry,
|
||||
type VerseRecord,
|
||||
} from "@/lib/api";
|
||||
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type BsMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
|
||||
|
||||
// Fallback functions that read from local JSON when DB is empty
|
||||
async function getMetaWithFallback() {
|
||||
const meta = await getBookMeta("bs");
|
||||
if (meta) return meta as unknown as BsMeta;
|
||||
|
||||
const introPath = path.join(BS_JSON_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getVerseIndexWithFallback(
|
||||
chapter: number,
|
||||
): Promise<VerseIndexEntry[]> {
|
||||
const index = await getChapterVerseIndex("bs", null, chapter);
|
||||
if (index.length > 0) return index;
|
||||
|
||||
// Fallback: read verse files from local JSON
|
||||
const versesDir = path.join(
|
||||
BS_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapter).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
|
||||
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
return files.map((f): VerseIndexEntry => {
|
||||
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
|
||||
return {
|
||||
verse: v.verse,
|
||||
verseEnd: v.verse_end || null,
|
||||
reference: v.reference,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getVerseWithFallback(
|
||||
chapter: number,
|
||||
verse: number,
|
||||
): Promise<VerseRecord | null> {
|
||||
const v = await getSingleVerse("bs", null, chapter, verse);
|
||||
if (v) return v;
|
||||
|
||||
// Fallback: read from local JSON
|
||||
const versesDir = path.join(
|
||||
BS_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapter).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (!fs.existsSync(versesDir)) return null;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const f of files) {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
|
||||
if (data.verse === verse) {
|
||||
return {
|
||||
id: `bs-${chapter}-${verse}`,
|
||||
book: "bs",
|
||||
division: null,
|
||||
chapter: data.chapter,
|
||||
verse: data.verse,
|
||||
verseEnd: data.verse_end || null,
|
||||
reference: data.reference,
|
||||
bookTitle: data.book_title,
|
||||
devanagari: data.devanagari,
|
||||
bengali: null,
|
||||
transliteration: data.transliteration,
|
||||
synonyms: data.synonyms,
|
||||
translation: data.translation,
|
||||
purport: data.purport,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter, verse } = await params;
|
||||
const sp = await searchParams;
|
||||
const v = await getVerseWithFallback(parseInt(chapter), parseInt(verse));
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: "Śrī Brahma-saṁhitā",
|
||||
detail: `Chapter ${chapter}, Verse ${parseInt(verse)}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForVerse("bs", {
|
||||
chapter: parseInt(chapter),
|
||||
verse: parseInt(verse),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function VersePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
}) {
|
||||
const { chapter, verse: verseParam } = await params;
|
||||
|
||||
const bsMeta = await getMetaWithFallback();
|
||||
if (!bsMeta) notFound();
|
||||
|
||||
const chapterInfo = bsMeta.chapters?.find(
|
||||
(c) => c.number === parseInt(chapter),
|
||||
);
|
||||
if (!chapterInfo?.title) notFound();
|
||||
|
||||
const title: string = chapterInfo.title;
|
||||
|
||||
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
|
||||
if (verseIndex.length === 0) notFound();
|
||||
|
||||
const requestedVerse = parseInt(verseParam);
|
||||
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
|
||||
if (!verse) notFound();
|
||||
|
||||
// Build mobile nav groups — verses for the current chapter
|
||||
const mobileNavGroups: MobileNavGroup[] = [
|
||||
{
|
||||
title: "Verses",
|
||||
items: verseIndex.map((v) => ({
|
||||
slug: String(v.verse),
|
||||
label: v.verseEnd
|
||||
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
|
||||
: `Verse ${chapter}.${v.verse}`,
|
||||
href: `/books/bs/${chapter}/${v.verse}`,
|
||||
type: "verse",
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/bs"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Śrī Brahma-saṁhitā
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
||||
Chapter {chapter}: {title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
<ChapterViewer
|
||||
initialVerse={verse}
|
||||
initialVerseNum={requestedVerse}
|
||||
verseIndex={verseIndex.map((v) => ({
|
||||
verse: v.verse,
|
||||
verseEnd: v.verseEnd ?? undefined,
|
||||
reference: v.reference,
|
||||
}))}
|
||||
book="bs"
|
||||
division={null}
|
||||
chapter={parseInt(chapter)}
|
||||
basePath={`/books/bs/${chapter}`}
|
||||
showBengali={false}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getChapterVerseIndex } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import Link from "next/link";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
|
||||
|
||||
type IntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter } = await params;
|
||||
return buildOgMeta({ title: BOOK_NAMES["bs"], detail: `Chapter ${chapter}` });
|
||||
}
|
||||
|
||||
export default async function BsChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter } = await params;
|
||||
|
||||
// Handle intro segments (intro-introduction, intro-foreword)
|
||||
if (chapter.startsWith("intro-")) {
|
||||
const rawSlug = chapter.replace(/^intro-/, "");
|
||||
const filePath = path.join(BS_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) notFound();
|
||||
|
||||
let segment: IntroSegment;
|
||||
try {
|
||||
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/bs"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Śrī Brahma-saṁhitā
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{segment.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{segment.paragraphs.map((paragraph: string, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle numeric chapters — redirect to first verse
|
||||
const chapterNum = parseInt(chapter);
|
||||
if (isNaN(chapterNum)) notFound();
|
||||
|
||||
// Try DB first
|
||||
const verseIndex = await getChapterVerseIndex("bs", null, chapterNum);
|
||||
|
||||
// Fallback to local JSON
|
||||
if (verseIndex.length === 0) {
|
||||
const versesDir = path.join(
|
||||
BS_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (fs.existsSync(versesDir)) {
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f: string) => f.endsWith(".json"))
|
||||
.sort();
|
||||
if (files.length > 0) {
|
||||
const first = JSON.parse(
|
||||
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
|
||||
);
|
||||
redirect(`/books/bs/${chapter}/${first.verse}`);
|
||||
}
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
redirect(`/books/bs/${chapter}/${verseIndex[0].verse}`);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("bs");
|
||||
}
|
||||
|
||||
type BsMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year?: number;
|
||||
description?: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
type BsIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
|
||||
|
||||
async function getMetaWithFallback(): Promise<BsMeta | null> {
|
||||
const meta = await getBookMeta("bs");
|
||||
if (meta) return meta as unknown as BsMeta;
|
||||
|
||||
const introPath = path.join(BS_JSON_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalIntroSegments(): BsIntroSegment[] {
|
||||
const introDir = path.join(BS_JSON_DIR, "intro");
|
||||
if (!fs.existsSync(introDir)) return [];
|
||||
|
||||
const results: BsIntroSegment[] = [];
|
||||
const files = ["introduction.json", "foreword.json"];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
results.push(
|
||||
JSON.parse(fs.readFileSync(filePath, "utf-8")) as BsIntroSegment,
|
||||
);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export default async function BsPage() {
|
||||
const meta = await getMetaWithFallback();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = getLocalIntroSegments();
|
||||
const introduction = introSegments.find((s) => s.slug === "introduction");
|
||||
const foreword = introSegments.find((s) => s.slug === "foreword");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
{meta.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{meta.description}
|
||||
</p>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Front Matter */}
|
||||
{(introduction || foreword) && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introduction && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/bs/intro-introduction"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{foreword && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/bs/intro-foreword"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{foreword.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border py-0">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/bs/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
<ExtractedVerseCount count={ch.totalVerses} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getCbhcContent,
|
||||
getCbhcSidebarItems,
|
||||
type CbhcChapter,
|
||||
type CbhcSidebarItem,
|
||||
} from "@/lib/cbhc";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("cbhc", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["cbhc"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("cbhc", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function CbhcChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getCbhcContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getCbhcSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const chapterNum = (content as CbhcChapter).chapter ?? null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/cbhc"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Śrī Caitanya-bhāgavata Commentary
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<CbhcSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<CbhcNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<CbhcNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CbhcSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: CbhcSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: CbhcSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/cbhc/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CbhcNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: CbhcSidebarItem | null;
|
||||
next: CbhcSidebarItem | null;
|
||||
sidebarItems: CbhcSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/cbhc/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Śrī Caitanya-bhāgavata Commentary"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/cbhc/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/cbhc/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getCbhcMeta } from "@/lib/cbhc";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("cbhc");
|
||||
}
|
||||
|
||||
export default async function CbhcPage() {
|
||||
const meta = await getCbhcMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/cbhc/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
|
||||
type ChapterInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalVerses: number;
|
||||
};
|
||||
|
||||
type LilaInfo = {
|
||||
key: string;
|
||||
name: string;
|
||||
totalChapters: number;
|
||||
totalVerses: number;
|
||||
chapters: ChapterInfo[];
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ lila: string; chapter: string; verse: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { lila, chapter, verse } = await params;
|
||||
const sp = await searchParams;
|
||||
const v = await getSingleVerse(
|
||||
"cc",
|
||||
lila,
|
||||
parseInt(chapter),
|
||||
parseInt(verse),
|
||||
);
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
const bookTitle = "Śrī Caitanya-caritāmṛta";
|
||||
return buildOgMeta({
|
||||
title: bookTitle,
|
||||
detail: `${lila.charAt(0).toUpperCase() + lila.slice(1)}, Chapter ${chapter}, Verse ${verse}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForVerse("cc", {
|
||||
division: lila,
|
||||
chapter: parseInt(chapter),
|
||||
verse: parseInt(verse),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function CcVersePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lila: string; chapter: string; verse: string }>;
|
||||
}) {
|
||||
const { lila, chapter, verse } = await params;
|
||||
|
||||
const meta = await getBookMeta("cc");
|
||||
if (!meta) notFound();
|
||||
|
||||
const lilas = meta.lilas as LilaInfo[] | undefined;
|
||||
const lilaInfo = lilas?.find((l) => l.key === lila);
|
||||
if (!lilaInfo) notFound();
|
||||
|
||||
const chapterInfo = lilaInfo.chapters.find(
|
||||
(ch) => ch.number === parseInt(chapter),
|
||||
);
|
||||
if (!chapterInfo) notFound();
|
||||
|
||||
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
|
||||
if (verseIndex.length === 0) notFound();
|
||||
|
||||
const requestedVerse = parseInt(verse);
|
||||
const verseData = await getSingleVerse(
|
||||
"cc",
|
||||
lila,
|
||||
parseInt(chapter),
|
||||
requestedVerse,
|
||||
);
|
||||
if (!verseData) notFound();
|
||||
|
||||
// Build mobile nav groups from verse index
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
if (verseIndex.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Verses",
|
||||
items: verseIndex.map((v) => ({
|
||||
slug: String(v.verse),
|
||||
label: v.verseEnd
|
||||
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
|
||||
: `Verse ${chapter}.${v.verse}`,
|
||||
href: `/books/cc/${lila}/${chapter}/${v.verse}`,
|
||||
type: "verse",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href={`/books/cc/${lila}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← {lilaInfo.name}
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
||||
Chapter {chapter}: {chapterInfo.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
<ChapterViewer
|
||||
initialVerse={verseData}
|
||||
initialVerseNum={requestedVerse}
|
||||
verseIndex={verseIndex.map((v) => ({
|
||||
verse: v.verse,
|
||||
verseEnd: v.verseEnd ?? undefined,
|
||||
reference: v.reference,
|
||||
}))}
|
||||
book="cc"
|
||||
division={lila}
|
||||
chapter={parseInt(chapter)}
|
||||
showBengali={true}
|
||||
basePath={`/books/cc/${lila}/${chapter}`}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getChapterVerseIndex } from "@/lib/api";
|
||||
import { CC_LILAS } from "@/lib/constants";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lila: string; chapter: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { lila, chapter } = await params;
|
||||
const lilaName = CC_LILAS[lila] || lila;
|
||||
return buildOgMeta({
|
||||
title: "Śrī Caitanya-caritāmṛta",
|
||||
detail: `${lilaName}, Chapter ${chapter}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function CcChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lila: string; chapter: string }>;
|
||||
}) {
|
||||
const { lila, chapter } = await params;
|
||||
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
|
||||
if (verseIndex.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
redirect(`/books/cc/${lila}/${chapter}/${verseIndex[0].verse}`);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ExtractedVerseCount } from "../../extracted-verse-count";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lila: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { lila } = await params;
|
||||
const lilaName = CC_LILAS[lila] || lila;
|
||||
const bookTitle = BOOK_NAMES["cc"] || "Śrī Caitanya-caritāmṛta";
|
||||
return buildOgMeta({
|
||||
title: bookTitle,
|
||||
detail: lilaName,
|
||||
});
|
||||
}
|
||||
|
||||
type ChapterInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalVerses: number;
|
||||
};
|
||||
|
||||
type LilaInfo = {
|
||||
key: string;
|
||||
name: string;
|
||||
totalChapters: number;
|
||||
totalVerses: number;
|
||||
chapters: ChapterInfo[];
|
||||
};
|
||||
|
||||
export default async function LilaPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lila: string }>;
|
||||
}) {
|
||||
const { lila } = await params;
|
||||
const meta = await getBookMeta("cc");
|
||||
if (!meta) notFound();
|
||||
|
||||
const lilas = meta.lilas as LilaInfo[];
|
||||
const lilaInfo = lilas.find((l) => l.key === lila);
|
||||
if (!lilaInfo) notFound();
|
||||
|
||||
const totalVerses = lilaInfo.chapters.reduce(
|
||||
(sum, ch) => sum + ch.totalVerses,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books/cc"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Śrī Caitanya-caritāmṛta
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{lilaInfo.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{lilaInfo.totalChapters} chapters ·{" "}
|
||||
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
|
||||
</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{lilaInfo.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/cc/${lila}/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">{ch.title}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
<ExtractedVerseCount count={ch.totalVerses} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||
|
||||
type ChapterInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalVerses: number;
|
||||
};
|
||||
|
||||
type LilaInfo = {
|
||||
key: string;
|
||||
name: string;
|
||||
totalChapters: number;
|
||||
totalVerses: number;
|
||||
chapters: ChapterInfo[];
|
||||
};
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("cc");
|
||||
}
|
||||
|
||||
export default async function CcPage() {
|
||||
const meta = await getBookMeta("cc");
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
Book metadata not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lilas = meta.lilas as LilaInfo[];
|
||||
const title = meta.title as string;
|
||||
const author = meta.author as string;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
|
||||
<p className="mt-1 text-muted-foreground">{author}</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{lilas.map((lila) => {
|
||||
const totalVerses = lila.chapters.reduce(
|
||||
(sum, ch) => sum + ch.totalVerses,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<li key={lila.key}>
|
||||
<Link
|
||||
href={`/books/cc/${lila.key}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-1">
|
||||
{lila.name}
|
||||
</Badge>
|
||||
<p className="font-medium text-foreground">
|
||||
{lila.totalChapters} chapters
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
<ExtractedVerseCount
|
||||
count={totalVerses}
|
||||
label="extracted verses"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useEffect, useState } from "react";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Synonym = {
|
||||
word: string;
|
||||
meanings: string[];
|
||||
};
|
||||
|
||||
type Verse = {
|
||||
id: string;
|
||||
book: string;
|
||||
division: string | null;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
verseEnd: number | null;
|
||||
reference: string;
|
||||
bookTitle: string;
|
||||
devanagari: string;
|
||||
bengali: string | null;
|
||||
transliteration: string;
|
||||
synonyms: Synonym[];
|
||||
translation: string;
|
||||
purport: string[];
|
||||
};
|
||||
|
||||
type VerseIndexEntry = {
|
||||
verse: number;
|
||||
verseEnd?: number;
|
||||
reference: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ChapterViewerProps = {
|
||||
/** The first verse (SSR'd), shown before any client-side fetch */
|
||||
initialVerse: Verse;
|
||||
/** The verse number of the initial verse */
|
||||
initialVerseNum: number;
|
||||
/** Lightweight index of ALL verses in the chapter (for the sidebar) */
|
||||
verseIndex: VerseIndexEntry[];
|
||||
/** Book key */
|
||||
book: string;
|
||||
/** Division: null for BG, canto string for SB, lila key for CC */
|
||||
division: string | null;
|
||||
/** Chapter number */
|
||||
chapter: number;
|
||||
/** Whether to show the Bengali script field (CC only) */
|
||||
showBengali?: boolean;
|
||||
/** Base path for verse URLs, e.g. "/books/bg/1" — verse number is appended */
|
||||
basePath: string;
|
||||
/** Mobile nav groups for the drawer */
|
||||
mobileNavGroups?: MobileNavGroup[];
|
||||
/** Book title for the mobile nav drawer header */
|
||||
mobileNavBookTitle?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigation buttons (reused at top and bottom)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VerseNav({
|
||||
currentIdx,
|
||||
total,
|
||||
onPrev,
|
||||
onNext,
|
||||
verseIndex,
|
||||
mobileNavGroups,
|
||||
currentVerseSlug,
|
||||
mobileNavBookTitle,
|
||||
}: {
|
||||
currentIdx: number;
|
||||
total: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
verseIndex: VerseIndexEntry[];
|
||||
mobileNavGroups?: MobileNavGroup[];
|
||||
currentVerseSlug?: string;
|
||||
mobileNavBookTitle?: string;
|
||||
}) {
|
||||
const hasPrev = currentIdx > 0;
|
||||
const hasNext = currentIdx < total - 1;
|
||||
const current = verseIndex[currentIdx];
|
||||
const label = current
|
||||
? current.verseEnd
|
||||
? `${current.verse}–${current.verseEnd}`
|
||||
: String(current.verse)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{mobileNavGroups && mobileNavGroups.length > 0 && (
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentVerseSlug ?? String(current?.verse ?? "")}
|
||||
bookTitle={mobileNavBookTitle}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPrev}
|
||||
onClick={onPrev}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline">Previous</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Verse {label} of {total}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasNext}
|
||||
onClick={onNext}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verse card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VerseCard({
|
||||
verse,
|
||||
showBengali,
|
||||
}: {
|
||||
verse: Verse;
|
||||
showBengali: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card id={`verse-${verse.verse}`} className="">
|
||||
<CardHeader className="border-b border-border bg-muted/50 py-3">
|
||||
<Badge variant="secondary">{verse.reference}</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5 pt-5">
|
||||
{/* Bengali (CC only) */}
|
||||
{showBengali && verse.bengali && (
|
||||
<div className="text-center">
|
||||
<p className="text-xl leading-relaxed whitespace-pre-line font-serif text-green-800 dark:text-green-400">
|
||||
{verse.bengali}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Devanagari */}
|
||||
<div className="text-center">
|
||||
<p className="text-xl leading-relaxed text-red-800 dark:text-white whitespace-pre-line font-serif">
|
||||
{verse.devanagari}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transliteration */}
|
||||
<div className="text-center">
|
||||
<p className="text-base italic text-muted-foreground whitespace-pre-line">
|
||||
{verse.transliteration}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Synonyms */}
|
||||
{verse.synonyms.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Synonyms
|
||||
</h3>
|
||||
<p className="text-base text-foreground leading-relaxed">
|
||||
{verse.synonyms.map((s, i) => (
|
||||
<span key={i}>
|
||||
<span className="italic text-foreground">{s.word}</span>—
|
||||
{s.meanings.join(", ")}
|
||||
{i < verse.synonyms.length - 1 ? "; " : "."}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Translation */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Translation
|
||||
</h3>
|
||||
<p
|
||||
className="text-base text-foreground leading-relaxed font-medium"
|
||||
dangerouslySetInnerHTML={{ __html: verse.translation }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Purport */}
|
||||
{verse.purport.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Purport
|
||||
</h3>
|
||||
<div className="text-base text-foreground leading-relaxed space-y-3">
|
||||
{verse.purport.map((p, i) => (
|
||||
<p key={i} dangerouslySetInnerHTML={{ __html: p }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChapterViewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ChapterViewer({
|
||||
initialVerse,
|
||||
initialVerseNum,
|
||||
verseIndex,
|
||||
book,
|
||||
division,
|
||||
chapter,
|
||||
showBengali = false,
|
||||
basePath,
|
||||
mobileNavGroups,
|
||||
mobileNavBookTitle,
|
||||
}: ChapterViewerProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Verse cache: verseNum → Verse data
|
||||
const [cache, setCache] = useState<Map<number, Verse>>(() => {
|
||||
const map = new Map<number, Verse>();
|
||||
map.set(initialVerseNum, initialVerse);
|
||||
return map;
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Current verse number — driven by the initialVerseNum from the server
|
||||
// (which reads it from the [verse] path param)
|
||||
const currentVerseNum = initialVerseNum;
|
||||
|
||||
// Current index in the verseIndex array
|
||||
const currentIdx = useMemo(
|
||||
() => verseIndex.findIndex((v) => v.verse === currentVerseNum),
|
||||
[verseIndex, currentVerseNum],
|
||||
);
|
||||
|
||||
// The verse data to display
|
||||
const currentVerse = cache.get(currentVerseNum) ?? null;
|
||||
|
||||
// Reset cache when route changes to a different chapter
|
||||
useEffect(() => {
|
||||
const map = new Map<number, Verse>();
|
||||
map.set(initialVerseNum, initialVerse);
|
||||
setCache(map);
|
||||
}, [initialVerse, initialVerseNum]);
|
||||
|
||||
// Fetch a verse if it's not in the cache
|
||||
const fetchVerse = useCallback(
|
||||
async (verseNum: number) => {
|
||||
if (cache.has(verseNum)) return cache.get(verseNum)!;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
chapter: String(chapter),
|
||||
verse: String(verseNum),
|
||||
});
|
||||
if (division !== null) {
|
||||
params.set("division", division);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/books/${book}/verses?${params}`);
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
// The API returns the verse in a paginated wrapper or an array
|
||||
const verse: Verse | undefined = Array.isArray(data.verses)
|
||||
? data.verses[0]
|
||||
: Array.isArray(data)
|
||||
? data[0]
|
||||
: undefined;
|
||||
|
||||
if (verse) {
|
||||
setCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(verseNum, verse);
|
||||
return next;
|
||||
});
|
||||
return verse;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch verse:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[cache, book, division, chapter],
|
||||
);
|
||||
|
||||
// Navigate to a verse — update the URL path and fetch if needed
|
||||
const navigateTo = useCallback(
|
||||
async (verseNum: number) => {
|
||||
// Prefetch the verse data before navigating
|
||||
if (!cache.has(verseNum)) {
|
||||
await fetchVerse(verseNum);
|
||||
}
|
||||
|
||||
// Navigate to the new path-based URL
|
||||
router.push(`${basePath}/${verseNum}`, { scroll: false });
|
||||
|
||||
// Scroll to top of the verse
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
[basePath, router, cache, fetchVerse],
|
||||
);
|
||||
|
||||
// Fetch current verse on mount/URL change if not cached
|
||||
useEffect(() => {
|
||||
if (!cache.has(currentVerseNum)) {
|
||||
fetchVerse(currentVerseNum);
|
||||
}
|
||||
}, [currentVerseNum, cache, fetchVerse]);
|
||||
|
||||
// Prefetch adjacent verses
|
||||
useEffect(() => {
|
||||
if (currentIdx > 0) {
|
||||
const prevNum = verseIndex[currentIdx - 1].verse;
|
||||
if (!cache.has(prevNum)) fetchVerse(prevNum);
|
||||
}
|
||||
if (currentIdx < verseIndex.length - 1) {
|
||||
const nextNum = verseIndex[currentIdx + 1].verse;
|
||||
if (!cache.has(nextNum)) fetchVerse(nextNum);
|
||||
}
|
||||
}, [currentIdx, verseIndex, cache, fetchVerse]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
|
||||
if (e.key === "ArrowLeft" && currentIdx > 0) {
|
||||
e.preventDefault();
|
||||
navigateTo(verseIndex[currentIdx - 1].verse);
|
||||
} else if (e.key === "ArrowRight" && currentIdx < verseIndex.length - 1) {
|
||||
e.preventDefault();
|
||||
navigateTo(verseIndex[currentIdx + 1].verse);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [currentIdx, verseIndex, navigateTo]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (currentIdx > 0) navigateTo(verseIndex[currentIdx - 1].verse);
|
||||
}, [currentIdx, verseIndex, navigateTo]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (currentIdx < verseIndex.length - 1)
|
||||
navigateTo(verseIndex[currentIdx + 1].verse);
|
||||
}, [currentIdx, verseIndex, navigateTo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ---- Sidebar: Verse Index ---- */}
|
||||
{/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for
|
||||
the site footer so the sidebar doesn't slide behind it when the
|
||||
page is scrolled to the bottom. (89 sticky-top + 76 bottom gap
|
||||
= 165, hence calc(100vh - 165px).) */}
|
||||
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-165px)] overflow-y-auto py-8 pr-4">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Verses
|
||||
</h2>
|
||||
<nav className="grid grid-cols-3 gap-1">
|
||||
{verseIndex.map((v) => {
|
||||
const isActive = currentVerseNum === v.verse;
|
||||
const label = v.verseEnd
|
||||
? `${v.verse}–${v.verseEnd}`
|
||||
: String(v.verse);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={v.verse}
|
||||
href={`${basePath}/${v.verse}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigateTo(v.verse);
|
||||
}}
|
||||
className={`px-2 py-1 rounded-md text-sm tabular-nums transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* ---- Main: Single Verse ---- */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-6">
|
||||
{/* Top navigation */}
|
||||
<VerseNav
|
||||
currentIdx={currentIdx}
|
||||
total={verseIndex.length}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
verseIndex={verseIndex}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
currentVerseSlug={String(currentVerseNum)}
|
||||
mobileNavBookTitle={mobileNavBookTitle}
|
||||
/>
|
||||
|
||||
{/* Verse content */}
|
||||
{loading && !currentVerse ? (
|
||||
<div className="flex items-center justify-center py-24 text-muted-foreground gap-2">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span>Loading verse…</span>
|
||||
</div>
|
||||
) : currentVerse ? (
|
||||
<VerseCard verse={currentVerse} showBengali={showBengali} />
|
||||
) : (
|
||||
<div className="text-center py-24 text-muted-foreground">
|
||||
Verse not found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<VerseNav
|
||||
currentIdx={currentIdx}
|
||||
total={verseIndex.length}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
verseIndex={verseIndex}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
currentVerseSlug={String(currentVerseNum)}
|
||||
mobileNavBookTitle={mobileNavBookTitle}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getDsContent,
|
||||
getDsSidebarItems,
|
||||
type DsChapter,
|
||||
type DsSidebarItem,
|
||||
} from "@/lib/ds";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("ds", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["ds"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("ds", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function DsChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getDsContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getDsSidebarItems();
|
||||
|
||||
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
|
||||
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
|
||||
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < navigableItems.length - 1
|
||||
? navigableItems[currentIdx + 1]
|
||||
: null;
|
||||
|
||||
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
|
||||
const isSubChapter =
|
||||
"parentChapter" in content &&
|
||||
typeof (content as DsChapter).parentChapter === "number";
|
||||
const parentChapterNum = isSubChapter
|
||||
? (content as DsChapter).parentChapter
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/ds"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Dialectic Spiritualism
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{parentChapterNum != null && (
|
||||
<span className="text-primary">Chapter {parentChapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<DsSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<DsNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<DsNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DsSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: DsSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items by section
|
||||
const introItems = items.filter((i) => i.type === "intro-segment");
|
||||
const parents = items.filter((i) => i.type === "parent");
|
||||
const subChapters = items.filter((i) => i.type === "sub-chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters — grouped by parent */}
|
||||
{parents.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{parents.map((parent) => (
|
||||
<div key={parent.slug}>
|
||||
{/* Parent heading — not a link, just a bold label */}
|
||||
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
|
||||
{parent.label}
|
||||
</div>
|
||||
{/* Sub-chapters indented */}
|
||||
{subChapters
|
||||
.filter((sub) => sub.parentChapter === parent.parentChapter)
|
||||
.map((sub) => {
|
||||
const isActive = sub.slug === currentSlug;
|
||||
return (
|
||||
<Link
|
||||
key={sub.slug}
|
||||
href={`/books/ds/${sub.slug}`}
|
||||
className={cn(
|
||||
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={sub.label}
|
||||
>
|
||||
{sub.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: DsSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ds/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DsNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: DsSidebarItem | null;
|
||||
next: DsSidebarItem | null;
|
||||
sidebarItems: DsSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ds/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter(
|
||||
(i) => i.type === "parent" || i.type === "sub-chapter",
|
||||
);
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ds/${i.slug}`,
|
||||
type: i.type,
|
||||
parentChapter: i.parentChapter,
|
||||
isParent: i.type === "parent",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Dialectic Spiritualism"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ds/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ds/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getDsMeta, getDsIntroSegments } from "@/lib/ds";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("ds");
|
||||
}
|
||||
|
||||
export default async function DsPage() {
|
||||
const meta = await getDsMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getDsIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
{meta.subtitle && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{meta.subtitle}</p>
|
||||
)}
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ds/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters — hierarchical parent → sub-chapter structure */}
|
||||
{meta.chapters.map((ch) => (
|
||||
<div key={ch.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{ch.number}. {ch.title}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{ch.subChapters.map((sub) => (
|
||||
<li key={sub.slug}>
|
||||
<Link
|
||||
href={`/books/ds/${sub.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{sub.title}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getEjContent,
|
||||
getEjSidebarItems,
|
||||
type EjChapter,
|
||||
type EjSidebarItem,
|
||||
} from "@/lib/ej";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("ej", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["ej"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("ej", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function EjChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getEjContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getEjSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as EjChapter).chapter === "number" &&
|
||||
(content as EjChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as EjChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/ej"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Easy Journey to Other Planets
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<EjSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<EjNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<EjNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EjSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: EjSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: EjSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ej/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EjNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: EjSidebarItem | null;
|
||||
next: EjSidebarItem | null;
|
||||
sidebarItems: EjSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ej/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ej/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Easy Journey to Other Planets"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ej/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ej/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getEjMeta, getEjIntroSegments } from "@/lib/ej";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("ej");
|
||||
}
|
||||
|
||||
export default async function EjPage() {
|
||||
const meta = await getEjMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getEjIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground italic">
|
||||
{meta.subtitle}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ej/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/ej/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getEkContent,
|
||||
getEkSidebarItems,
|
||||
type EkChapter,
|
||||
type EkSidebarItem,
|
||||
} from "@/lib/ek";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("ek", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["ek"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("ek", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function EkChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getEkContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getEkSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as EkChapter).chapter === "number" &&
|
||||
(content as EkChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as EkChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/ek"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Elevation to Kṛṣṇa Consciousness
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<EkSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<EkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<EkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EkSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: EkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: EkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ek/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EkNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: EkSidebarItem | null;
|
||||
next: EkSidebarItem | null;
|
||||
sidebarItems: EkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ek/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ek/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Elevation to Kṛṣṇa Consciousness"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ek/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ek/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getEkMeta, getEkIntroSegments } from "@/lib/ek";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("ek");
|
||||
}
|
||||
|
||||
export default async function EkPage() {
|
||||
const meta = await getEkMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getEkIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ek/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/ek/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const TOOLTIP_TEXT =
|
||||
"This is the number of extracted verse entries from Śrīla Prabhupāda's original translations and purports. Some consecutive verses are merged into a single entry (e.g. Texts 16–18), so the actual verse count in the original manuscript may differ.";
|
||||
|
||||
export function ExtractedVerseCount({
|
||||
count,
|
||||
label = "verses",
|
||||
className = "",
|
||||
}: {
|
||||
count: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${className}`}>
|
||||
<span>
|
||||
{count.toLocaleString()} {label}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="inline-flex items-center justify-center opacity-50 hover:opacity-80 transition-opacity"
|
||||
aria-label="About verse count"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-pretty">
|
||||
{TOOLTIP_TEXT}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getGgContent,
|
||||
getGgSidebarItems,
|
||||
type GgChapter,
|
||||
type GgSidebarItem,
|
||||
} from "@/lib/gg";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("gg", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["gg"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("gg", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function GgChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getGgContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getGgSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as GgChapter).chapter === "number" &&
|
||||
(content as GgChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as GgChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/gg"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Gītār Gān
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<GgSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<GgNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<GgNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GgSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: GgSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items into groups for visual sections
|
||||
// Front matter: intro-segment items before chapters
|
||||
// Chapters: chapter items
|
||||
// Back matter: intro-segment items after chapters
|
||||
|
||||
const frontMatterItems: GgSidebarItem[] = [];
|
||||
const chapterItems: GgSidebarItem[] = [];
|
||||
const backMatterItems: GgSidebarItem[] = [];
|
||||
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatterItems.length > 0 && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{backMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: GgSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/gg/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GgNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: GgSidebarItem | null;
|
||||
next: GgSidebarItem | null;
|
||||
sidebarItems: GgSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const frontMatterItems: GgSidebarItem[] = [];
|
||||
const chapterItems: GgSidebarItem[] = [];
|
||||
const backMatterItems: GgSidebarItem[] = [];
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of sidebarItems) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/gg/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/gg/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (backMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Back Matter",
|
||||
items: backMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/gg/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Gītār Gān"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/gg/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/gg/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getGgMeta, getGgIntroSegments } from "@/lib/gg";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("gg");
|
||||
}
|
||||
|
||||
export default async function GgPage() {
|
||||
const meta = await getGgMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getGgIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 99);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 99);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/gg/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getHkhContent,
|
||||
getHkhSidebarItems,
|
||||
type HkhChapter,
|
||||
type HkhSidebarItem,
|
||||
} from "@/lib/hkh";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("hkh", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["hkh"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("hkh", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function HkhChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getHkhContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getHkhSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as HkhChapter).chapter === "number" &&
|
||||
(content as HkhChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as HkhChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/hkh"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Kṛṣṇa Consciousness Handbook
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<HkhSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<HkhNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<HkhNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HkhSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: HkhSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: HkhSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/hkh/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HkhNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: HkhSidebarItem | null;
|
||||
next: HkhSidebarItem | null;
|
||||
sidebarItems: HkhSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/hkh/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/hkh/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="The Kṛṣṇa Consciousness Handbook"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/hkh/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/hkh/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getHkhMeta, getHkhIntroSegments } from "@/lib/hkh";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("hkh");
|
||||
}
|
||||
|
||||
export default async function HkhPage() {
|
||||
const meta = await getHkhMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getHkhIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/hkh/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/hkh/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getIsoContent,
|
||||
getIsoSidebarItems,
|
||||
type IsoVerse,
|
||||
type IsoIntroSegment,
|
||||
type IsoSidebarItem,
|
||||
} from "@/lib/iso";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isVerse(content: IsoVerse | IsoIntroSegment): content is IsoVerse {
|
||||
return "mantra" in content && "devanagari" in content;
|
||||
}
|
||||
|
||||
function isIntroSegment(
|
||||
content: IsoVerse | IsoIntroSegment,
|
||||
): content is IsoIntroSegment {
|
||||
return "paragraphs" in content && "order" in content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ mantra: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { mantra: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
|
||||
const content = await getIsoContent(slug);
|
||||
const isVerse_ = content && "mantra" in content && "devanagari" in content;
|
||||
const paragraphs = content
|
||||
? isVerse_
|
||||
? [
|
||||
(content as unknown as IsoVerse).translation,
|
||||
...(content as unknown as IsoVerse).purport,
|
||||
]
|
||||
: (content as unknown as IsoIntroSegment).paragraphs
|
||||
: undefined;
|
||||
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: "Śrī Īśopaniṣad",
|
||||
detail: `Mantra ${slug}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
|
||||
const title = isVerse_
|
||||
? `${(content as unknown as IsoVerse).reference}`
|
||||
: content && "title" in content
|
||||
? (content as IsoIntroSegment).title
|
||||
: `Mantra ${slug}`;
|
||||
|
||||
return ogForChapter("iso", title);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function IsoMantraPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ mantra: string }>;
|
||||
}) {
|
||||
const { mantra: slug } = await params;
|
||||
|
||||
const content = await getIsoContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getIsoSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title for the header
|
||||
const verse = isVerse(content) ? content : null;
|
||||
const intro = isIntroSegment(content) ? content : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/iso"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Śrī Īśopaniṣad
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{verse && (
|
||||
<>
|
||||
<span className="text-primary">{verse.reference}: </span>
|
||||
{verse.bookTitle}
|
||||
</>
|
||||
)}
|
||||
{intro && intro.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<IsoSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<IsoNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{verse && <VerseContent verse={verse} />}
|
||||
{intro && <IntroContent intro={intro} />}
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<IsoNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VerseContent({ verse }: { verse: IsoVerse }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
|
||||
{/* Devanagari */}
|
||||
<section>
|
||||
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
|
||||
{verse.devanagari}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transliteration */}
|
||||
<section>
|
||||
<div className="text-base text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
|
||||
{verse.transliteration}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Synonyms */}
|
||||
{verse.synonyms.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Synonyms
|
||||
</h2>
|
||||
<div className="text-base text-foreground leading-relaxed">
|
||||
{verse.synonyms.map((s, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && "; "}
|
||||
<span className="font-medium text-foreground/90">
|
||||
{s.word}
|
||||
</span>
|
||||
{"—"}
|
||||
{s.meanings.join(", ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Translation */}
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Translation
|
||||
</h2>
|
||||
<div
|
||||
className="text-base text-foreground font-medium leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: verse.translation }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Purport */}
|
||||
{verse.purport.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Purport
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{verse.purport.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: p }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segment content (paragraphs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IntroContent({ intro }: { intro: IsoIntroSegment }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{intro.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IsoSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: IsoSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Front matter items (front-pages + introduction)
|
||||
const frontMatterItems = items.filter(
|
||||
(item) =>
|
||||
item.slug === "intro-front-pages" || item.slug === "intro-introduction",
|
||||
);
|
||||
// Mantras = invocation + 1-18
|
||||
const mantraItems = items.filter((item) => item.type === "verse");
|
||||
// about-the-author comes last
|
||||
const aboutAuthorItem = items.find(
|
||||
(item) => item.slug === "intro-about-the-author",
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mantras */}
|
||||
{mantraItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Mantras
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{mantraItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* About the Author */}
|
||||
{aboutAuthorItem && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
<SidebarLink
|
||||
item={aboutAuthorItem}
|
||||
isActive={aboutAuthorItem.slug === currentSlug}
|
||||
/>
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: IsoSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/iso/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IsoNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: IsoSidebarItem | null;
|
||||
next: IsoSidebarItem | null;
|
||||
sidebarItems: IsoSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
// Front matter (front-pages + introduction)
|
||||
const frontMatterItems = sidebarItems.filter(
|
||||
(i) => i.slug === "intro-front-pages" || i.slug === "intro-introduction",
|
||||
);
|
||||
if (frontMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/iso/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Mantras (invocation + 1-18)
|
||||
const mantraItems = sidebarItems.filter((i) => i.type === "verse");
|
||||
if (mantraItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Mantras",
|
||||
items: mantraItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/iso/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Back matter (about-the-author)
|
||||
const backMatterItems = sidebarItems.filter(
|
||||
(i) => i.slug === "intro-about-the-author",
|
||||
);
|
||||
if (backMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Back Matter",
|
||||
items: backMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/iso/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Śrī Īśopaniṣad"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/iso/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "verse" && prev.mantra != null && prev.mantra > 0
|
||||
? `Mantra ${prev.mantra}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/iso/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "verse" && next.mantra != null && next.mantra > 0
|
||||
? `Mantra ${next.mantra}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIsoMeta, getIsoIntroSegments } from "@/lib/iso";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("iso");
|
||||
}
|
||||
|
||||
export default async function IsoPage() {
|
||||
const meta = await getIsoMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getIsoIntroSegments();
|
||||
|
||||
const frontPages = introSegments.find((s) => s.slug === "front-pages");
|
||||
const introduction = introSegments.find((s) => s.slug === "introduction");
|
||||
const aboutAuthor = introSegments.find((s) => s.slug === "about-the-author");
|
||||
|
||||
// Invocation is mantra 0, mantras are 1-18
|
||||
const invocation = meta.mantras.find((m) => m.number === 0);
|
||||
const numberedMantras = meta.mantras.filter((m) => m.number > 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{(frontPages || introduction) && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontPages && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/iso/intro-front-pages"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{frontPages.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{introduction && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/iso/intro-introduction"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{introduction.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mantras */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Mantras
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{/* Invocation */}
|
||||
{invocation && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/iso/invocation"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Invocation
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{invocation.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* Mantras 1-18 */}
|
||||
{numberedMantras.map((m) => (
|
||||
<li key={m.number}>
|
||||
<Link
|
||||
href={`/books/iso/${m.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Mantra {m.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">{m.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* About the Author */}
|
||||
{aboutAuthor && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
About the Author
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/iso/intro-about-the-author"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{aboutAuthor.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getProseChapter, getProseChapters } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ volume: string; slug: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { volume, slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const vol = parseInt(volume);
|
||||
const chapter = !isNaN(vol) ? await getProseChapter("kb", vol, slug) : null;
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({
|
||||
title: BOOK_NAMES["kb"],
|
||||
detail: chapter ? `Volume ${vol} — ${chapter.title}` : `Volume ${vol}`,
|
||||
quote: hlQuote,
|
||||
});
|
||||
}
|
||||
return ogForChapter(
|
||||
"kb",
|
||||
chapter ? `Volume ${vol} — ${chapter.title}` : `Volume ${vol}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default async function KbChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ volume: string; slug: string }>;
|
||||
}) {
|
||||
const { volume, slug } = await params;
|
||||
const vol = parseInt(volume);
|
||||
if (isNaN(vol) || vol < 1 || vol > 2) notFound();
|
||||
|
||||
const chapter = await getProseChapter("kb", vol, slug);
|
||||
if (!chapter) notFound();
|
||||
|
||||
// Fetch all chapters/segments in this volume for sidebar + prev/next
|
||||
const allChapters = await getProseChapters("kb", vol);
|
||||
const currentIdx = allChapters.findIndex((ch) => ch.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? allChapters[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < allChapters.length - 1 ? allChapters[currentIdx + 1] : null;
|
||||
|
||||
const chapterLabel =
|
||||
chapter.type === "chapter" && chapter.chapter != null
|
||||
? `Chapter ${chapter.chapter}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href={`/books/kb/${vol}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Volume {vol}
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterLabel && (
|
||||
<span className="text-primary">{chapterLabel}: </span>
|
||||
)}
|
||||
{chapter.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar — chapter index */}
|
||||
<ChapterSidebar
|
||||
chapters={allChapters}
|
||||
currentSlug={slug}
|
||||
volume={vol}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<ChapterNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
volume={vol}
|
||||
chapters={allChapters}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Chapter content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{chapter.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<ChapterNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
volume={vol}
|
||||
chapters={allChapters}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar — chapter index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SidebarChapter = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
function ChapterSidebar({
|
||||
chapters,
|
||||
currentSlug,
|
||||
volume,
|
||||
}: {
|
||||
chapters: SidebarChapter[];
|
||||
currentSlug: string;
|
||||
volume: number;
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapters.map((ch) => {
|
||||
const isActive = ch.slug === currentSlug;
|
||||
const label =
|
||||
ch.type === "chapter" && ch.chapter != null
|
||||
? `${ch.chapter}. ${ch.title}`
|
||||
: ch.title;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={ch.slug}
|
||||
href={`/books/kb/${volume}/${ch.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type NavChapter = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
chapter: number | null;
|
||||
};
|
||||
|
||||
function ChapterNav({
|
||||
prev,
|
||||
next,
|
||||
volume,
|
||||
chapters,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: NavChapter | null;
|
||||
next: NavChapter | null;
|
||||
volume: number;
|
||||
chapters: SidebarChapter[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = chapters.filter((i) => i.type !== "chapter");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.title,
|
||||
href: `/books/kb/${volume}/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = chapters.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.chapter != null ? `${i.chapter}. ${i.title}` : i.title,
|
||||
href: `/books/kb/${volume}/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle={`Kṛṣṇa Book — Volume ${volume}`}
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/kb/${volume}/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.title}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/kb/${volume}/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.title}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta, getProseChapters } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ volume: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { volume } = await params;
|
||||
const title = BOOK_NAMES["kb"] || "KB";
|
||||
return buildOgMeta({ title, detail: `Volume ${volume}` });
|
||||
}
|
||||
|
||||
export default async function KbVolumePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ volume: string }>;
|
||||
}) {
|
||||
const { volume } = await params;
|
||||
const vol = parseInt(volume);
|
||||
if (isNaN(vol) || vol < 1 || vol > 2) notFound();
|
||||
|
||||
const meta = await getBookMeta("kb");
|
||||
if (!meta) notFound();
|
||||
|
||||
const title = meta.title as string;
|
||||
|
||||
const [introSegments, chapters] = await Promise.all([
|
||||
getProseChapters("kb", vol, "intro-segment"),
|
||||
getProseChapters("kb", vol, "chapter"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books/kb"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← {title}
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
Volume {vol}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{chapters.length} chapters</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Front Matter */}
|
||||
{introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/kb/${vol}/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{chapters.map((ch) => (
|
||||
<li key={ch.slug}>
|
||||
<Link
|
||||
href={`/books/kb/${vol}/${ch.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.chapter}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type VolumeInfo = {
|
||||
number: number;
|
||||
totalChapters: number;
|
||||
};
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("kb");
|
||||
}
|
||||
|
||||
export default async function KbPage() {
|
||||
const meta = await getBookMeta("kb");
|
||||
if (!meta) notFound();
|
||||
|
||||
const title = meta.title as string;
|
||||
const author = meta.author as string;
|
||||
const description = meta.description as string | undefined;
|
||||
const volumes = meta.volumes as VolumeInfo[];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
|
||||
<p className="mt-1 text-muted-foreground">{author}</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
{description && (
|
||||
<p className="text-muted-foreground mb-6">{description}</p>
|
||||
)}
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{volumes.map((vol) => (
|
||||
<li key={vol.number}>
|
||||
<Link
|
||||
href={`/books/kb/${vol.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-1">
|
||||
Volume {vol.number}
|
||||
</Badge>
|
||||
<p className="font-medium text-foreground">
|
||||
{vol.totalChapters} chapters
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getLclContent,
|
||||
getLclSidebarItems,
|
||||
type LclChapter,
|
||||
type LclSidebarItem,
|
||||
} from "@/lib/lcl";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("lcl", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["lcl"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("lcl", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function LclChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getLclContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getLclSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as LclChapter).chapter === "number" &&
|
||||
(content as LclChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as LclChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/lcl"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Life Comes from Life
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<LclSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<LclNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<LclNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LclSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: LclSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items into groups for visual sections
|
||||
// Front matter: intro-segment items before chapters
|
||||
// Chapters: chapter items
|
||||
// Back matter: intro-segment items after chapters (about-the-author)
|
||||
|
||||
const frontMatterItems: LclSidebarItem[] = [];
|
||||
const chapterItems: LclSidebarItem[] = [];
|
||||
const backMatterItems: LclSidebarItem[] = [];
|
||||
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back Matter (About the Author) */}
|
||||
{backMatterItems.length > 0 && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{backMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: LclSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/lcl/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LclNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: LclSidebarItem | null;
|
||||
next: LclSidebarItem | null;
|
||||
sidebarItems: LclSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const frontMatterItems: LclSidebarItem[] = [];
|
||||
const chapterItems: LclSidebarItem[] = [];
|
||||
const backMatterItems: LclSidebarItem[] = [];
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of sidebarItems) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/lcl/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/lcl/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (backMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Back Matter",
|
||||
items: backMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/lcl/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Life Comes from Life"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/lcl/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/lcl/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getLclMeta, getLclIntroSegments } from "@/lib/lcl";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("lcl");
|
||||
}
|
||||
|
||||
export default async function LclPage() {
|
||||
const meta = await getLclMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getLclIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 99);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 99);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/lcl/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Back Matter (About the Author) */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getLobContent,
|
||||
getLobSidebarItems,
|
||||
type LobChapter,
|
||||
type LobSidebarItem,
|
||||
} from "@/lib/lob";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("lob", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["lob"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("lob", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function LobChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getLobContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getLobSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as LobChapter).chapter === "number" &&
|
||||
(content as LobChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as LobChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/lob"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Light of the Bhāgavata
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Text {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<LobSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<LobNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) =>
|
||||
paragraph.startsWith("<b>") ? (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed font-medium bg-muted/30 rounded-lg p-4 border border-border/50"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<LobNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LobSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: LobSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items into groups for visual sections
|
||||
// Front matter: intro-segment items before chapters
|
||||
// Texts: chapter items
|
||||
// (LoB has no back matter)
|
||||
|
||||
const frontMatterItems: LobSidebarItem[] = [];
|
||||
const textItems: LobSidebarItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "chapter") {
|
||||
textItems.push(item);
|
||||
} else {
|
||||
frontMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Texts */}
|
||||
{textItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Texts
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{textItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: LobSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Format: "N. Text N"
|
||||
label = `${item.chapter}. Text ${item.chapter}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/lob/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LobNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: LobSidebarItem | null;
|
||||
next: LobSidebarItem | null;
|
||||
sidebarItems: LobSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const frontMatterItems: LobSidebarItem[] = [];
|
||||
const textItems: LobSidebarItem[] = [];
|
||||
|
||||
for (const item of sidebarItems) {
|
||||
if (item.type === "chapter") {
|
||||
textItems.push(item);
|
||||
} else {
|
||||
frontMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/lob/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (textItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Texts",
|
||||
items: textItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/lob/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Light of the Bhāgavata"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/lob/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Text ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/lob/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Text ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getLobMeta, getLobIntroSegments } from "@/lib/lob";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("lob");
|
||||
}
|
||||
|
||||
export default async function LobPage() {
|
||||
const meta = await getLobMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getLobIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/lob/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Texts */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Texts
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/lob/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Text {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getMgContent,
|
||||
getMgSidebarItems,
|
||||
type MgChapter,
|
||||
type MgSidebarItem,
|
||||
} from "@/lib/mg";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("mg", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["mg"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("mg", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function MgChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getMgContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getMgSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as MgChapter).chapter === "number" &&
|
||||
(content as MgChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as MgChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/mg"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Kṛṣṇa Consciousness: The Matchless Gift
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<MgSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<MgNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<MgNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MgSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: MgSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: MgSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/mg/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MgNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: MgSidebarItem | null;
|
||||
next: MgSidebarItem | null;
|
||||
sidebarItems: MgSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/mg/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/mg/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Kṛṣṇa Consciousness: The Matchless Gift"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/mg/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/mg/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getMgMeta, getMgIntroSegments } from "@/lib/mg";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("mg");
|
||||
}
|
||||
|
||||
export default async function MgPage() {
|
||||
const meta = await getMgMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getMgIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/mg/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/mg/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getBookMeta,
|
||||
getSingleVerse,
|
||||
getChapterVerseIndex,
|
||||
type VerseIndexEntry,
|
||||
type VerseRecord,
|
||||
} from "@/lib/api";
|
||||
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||
import type { Metadata } from "next";
|
||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter, verse } = await params;
|
||||
const sp = await searchParams;
|
||||
const v = await getVerseWithFallback(parseInt(chapter), parseInt(verse));
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: "Mukunda-mālā-stotra",
|
||||
detail: `Chapter ${chapter}, Verse ${verse}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForVerse("mm", {
|
||||
chapter: parseInt(chapter),
|
||||
verse: parseInt(verse),
|
||||
});
|
||||
}
|
||||
|
||||
type MmMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
|
||||
|
||||
// Fallback functions that read from local JSON when DB is empty
|
||||
async function getMetaWithFallback() {
|
||||
const meta = await getBookMeta("mm");
|
||||
if (meta) return meta as unknown as MmMeta;
|
||||
|
||||
const introPath = path.join(MM_JSON_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getVerseIndexWithFallback(
|
||||
chapter: number,
|
||||
): Promise<VerseIndexEntry[]> {
|
||||
const index = await getChapterVerseIndex("mm", null, chapter);
|
||||
if (index.length > 0) return index;
|
||||
|
||||
// Fallback: read verse files from local JSON
|
||||
const versesDir = path.join(
|
||||
MM_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapter).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
|
||||
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
return files.map((f): VerseIndexEntry => {
|
||||
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
|
||||
return {
|
||||
verse: v.verse,
|
||||
verseEnd: v.verse_end || null,
|
||||
reference: v.reference,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getVerseWithFallback(
|
||||
chapter: number,
|
||||
verse: number,
|
||||
): Promise<VerseRecord | null> {
|
||||
const v = await getSingleVerse("mm", null, chapter, verse);
|
||||
if (v) return v;
|
||||
|
||||
// Fallback: read from local JSON
|
||||
const versesDir = path.join(
|
||||
MM_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapter).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (!fs.existsSync(versesDir)) return null;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const f of files) {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
|
||||
if (data.verse === verse) {
|
||||
return {
|
||||
id: `mm-${chapter}-${verse}`,
|
||||
book: "mm",
|
||||
division: null,
|
||||
chapter: data.chapter,
|
||||
verse: data.verse,
|
||||
verseEnd: data.verse_end || null,
|
||||
reference: data.reference,
|
||||
bookTitle: data.book_title,
|
||||
devanagari: data.devanagari,
|
||||
bengali: null,
|
||||
transliteration: data.transliteration,
|
||||
synonyms: data.synonyms,
|
||||
translation: data.translation,
|
||||
purport: data.purport,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function VersePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
}) {
|
||||
const { chapter, verse: verseParam } = await params;
|
||||
|
||||
const mmMeta = await getMetaWithFallback();
|
||||
if (!mmMeta) notFound();
|
||||
|
||||
const chapterInfo = mmMeta.chapters?.find(
|
||||
(c) => c.number === parseInt(chapter),
|
||||
);
|
||||
if (!chapterInfo?.title) notFound();
|
||||
|
||||
const title: string = chapterInfo.title;
|
||||
|
||||
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
|
||||
if (verseIndex.length === 0) notFound();
|
||||
|
||||
const requestedVerse = parseInt(verseParam);
|
||||
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
|
||||
if (!verse) notFound();
|
||||
|
||||
// Build mobile nav groups — verses for the current chapter
|
||||
const mobileNavGroups: MobileNavGroup[] = [
|
||||
{
|
||||
title: "Verses",
|
||||
items: verseIndex.map((v) => ({
|
||||
slug: String(v.verse),
|
||||
label: v.verseEnd
|
||||
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
|
||||
: `Verse ${chapter}.${v.verse}`,
|
||||
href: `/books/mm/${chapter}/${v.verse}`,
|
||||
type: "verse",
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/mm"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Mukunda-mālā-stotra
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
||||
Chapter {chapter}: {title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
<ChapterViewer
|
||||
initialVerse={verse}
|
||||
initialVerseNum={requestedVerse}
|
||||
verseIndex={verseIndex.map((v) => ({
|
||||
verse: v.verse,
|
||||
verseEnd: v.verseEnd ?? undefined,
|
||||
reference: v.reference,
|
||||
}))}
|
||||
book="mm"
|
||||
division={null}
|
||||
chapter={parseInt(chapter)}
|
||||
basePath={`/books/mm/${chapter}`}
|
||||
showBengali={false}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getChapterVerseIndex } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import Link from "next/link";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
|
||||
|
||||
type IntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter } = await params;
|
||||
return buildOgMeta({ title: BOOK_NAMES["mm"], detail: `Chapter ${chapter}` });
|
||||
}
|
||||
|
||||
export default async function MmChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter } = await params;
|
||||
|
||||
// Handle intro segments (intro-introduction)
|
||||
if (chapter.startsWith("intro-")) {
|
||||
const rawSlug = chapter.replace(/^intro-/, "");
|
||||
const filePath = path.join(MM_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) notFound();
|
||||
|
||||
let segment: IntroSegment;
|
||||
try {
|
||||
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/mm"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Mukunda-mālā-stotra
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{segment.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{segment.paragraphs.map((paragraph: string, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle numeric chapters — redirect to first verse
|
||||
const chapterNum = parseInt(chapter);
|
||||
if (isNaN(chapterNum)) notFound();
|
||||
|
||||
// Try DB first
|
||||
const verseIndex = await getChapterVerseIndex("mm", null, chapterNum);
|
||||
|
||||
// Fallback to local JSON
|
||||
if (verseIndex.length === 0) {
|
||||
const versesDir = path.join(
|
||||
MM_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (fs.existsSync(versesDir)) {
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f: string) => f.endsWith(".json"))
|
||||
.sort();
|
||||
if (files.length > 0) {
|
||||
const first = JSON.parse(
|
||||
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
|
||||
);
|
||||
redirect(`/books/mm/${chapter}/${first.verse}`);
|
||||
}
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
redirect(`/books/mm/${chapter}/${verseIndex[0].verse}`);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
type MmMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year?: number;
|
||||
description?: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
type MmIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
|
||||
|
||||
async function getMetaWithFallback(): Promise<MmMeta | null> {
|
||||
const meta = await getBookMeta("mm");
|
||||
if (meta) return meta as unknown as MmMeta;
|
||||
|
||||
const introPath = path.join(MM_JSON_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalIntroSegments(): MmIntroSegment[] {
|
||||
const introDir = path.join(MM_JSON_DIR, "intro");
|
||||
if (!fs.existsSync(introDir)) return [];
|
||||
|
||||
const results: MmIntroSegment[] = [];
|
||||
const files = ["introduction.json"];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
results.push(
|
||||
JSON.parse(fs.readFileSync(filePath, "utf-8")) as MmIntroSegment,
|
||||
);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("mm");
|
||||
}
|
||||
|
||||
export default async function MmPage() {
|
||||
const meta = await getMetaWithFallback();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = getLocalIntroSegments();
|
||||
const introduction = introSegments.find((s) => s.slug === "introduction");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
{meta.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{meta.description}
|
||||
</p>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Front Matter */}
|
||||
{introduction && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/mm/intro-introduction"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border py-0">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/mm/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
<ExtractedVerseCount count={ch.totalVerses} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getMogContent,
|
||||
getMogSidebarItems,
|
||||
type MogChapter,
|
||||
type MogSidebarItem,
|
||||
} from "@/lib/mog";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("mog", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["mog"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("mog", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function MogChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getMogContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getMogSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as MogChapter).chapter === "number" &&
|
||||
(content as MogChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as MogChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/mog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Message of Godhead
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<MogSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<MogNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<MogNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MogSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: MogSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: MogSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/mog/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MogNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: MogSidebarItem | null;
|
||||
next: MogSidebarItem | null;
|
||||
sidebarItems: MogSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/mog/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/mog/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Message of Godhead"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/mog/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/mog/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getMogMeta, getMogIntroSegments } from "@/lib/mog";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("mog");
|
||||
}
|
||||
|
||||
export default async function MogPage() {
|
||||
const meta = await getMogMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getMogIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/mog/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/mog/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getBookMeta,
|
||||
getSingleVerse,
|
||||
getChapterVerseIndex,
|
||||
type VerseIndexEntry,
|
||||
type VerseRecord,
|
||||
} from "@/lib/api";
|
||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
type NbsMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
|
||||
|
||||
// Fallback functions that read from local JSON when DB is empty
|
||||
async function getMetaWithFallback() {
|
||||
const meta = await getBookMeta("nbs");
|
||||
if (meta) return meta as unknown as NbsMeta;
|
||||
|
||||
const introPath = path.join(NBS_JSON_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getVerseIndexWithFallback(
|
||||
chapter: number,
|
||||
): Promise<VerseIndexEntry[]> {
|
||||
const index = await getChapterVerseIndex("nbs", null, chapter);
|
||||
if (index.length > 0) return index;
|
||||
|
||||
// Fallback: read verse files from local JSON
|
||||
const versesDir = path.join(
|
||||
NBS_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapter).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
|
||||
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
return files.map((f): VerseIndexEntry => {
|
||||
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
|
||||
return {
|
||||
verse: v.verse,
|
||||
verseEnd: v.verse_end || null,
|
||||
reference: v.reference,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getVerseWithFallback(
|
||||
chapter: number,
|
||||
verse: number,
|
||||
): Promise<VerseRecord | null> {
|
||||
const v = await getSingleVerse("nbs", null, chapter, verse);
|
||||
if (v) return v;
|
||||
|
||||
// Fallback: read from local JSON
|
||||
const versesDir = path.join(
|
||||
NBS_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapter).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (!fs.existsSync(versesDir)) return null;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const f of files) {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
|
||||
if (data.verse === verse) {
|
||||
return {
|
||||
id: `nbs-${chapter}-${verse}`,
|
||||
book: "nbs",
|
||||
division: null,
|
||||
chapter: data.chapter,
|
||||
verse: data.verse,
|
||||
verseEnd: data.verse_end || null,
|
||||
reference: data.reference,
|
||||
bookTitle: data.book_title,
|
||||
devanagari: data.devanagari,
|
||||
bengali: null,
|
||||
transliteration: data.transliteration,
|
||||
synonyms: data.synonyms,
|
||||
translation: data.translation,
|
||||
purport: data.purport,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter, verse } = await params;
|
||||
const sp = await searchParams;
|
||||
const v = await getSingleVerse(
|
||||
"nbs",
|
||||
null,
|
||||
parseInt(chapter),
|
||||
parseInt(verse),
|
||||
);
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: "Nārada-bhakti-sūtra",
|
||||
detail: `Chapter ${chapter}, Verse ${verse}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForVerse("nbs", {
|
||||
chapter: parseInt(chapter),
|
||||
verse: parseInt(verse),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function VersePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
}) {
|
||||
const { chapter, verse: verseParam } = await params;
|
||||
|
||||
const nbsMeta = await getMetaWithFallback();
|
||||
if (!nbsMeta) notFound();
|
||||
|
||||
const chapterInfo = nbsMeta.chapters?.find(
|
||||
(c) => c.number === parseInt(chapter),
|
||||
);
|
||||
if (!chapterInfo?.title) notFound();
|
||||
|
||||
const title: string = chapterInfo.title;
|
||||
|
||||
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
|
||||
if (verseIndex.length === 0) notFound();
|
||||
|
||||
const requestedVerse = parseInt(verseParam);
|
||||
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
|
||||
if (!verse) notFound();
|
||||
|
||||
// Build mobile nav groups — verses for the current chapter
|
||||
const mobileNavGroups: MobileNavGroup[] = [
|
||||
{
|
||||
title: "Verses",
|
||||
items: verseIndex.map((v) => ({
|
||||
slug: String(v.verse),
|
||||
label: v.verseEnd
|
||||
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
|
||||
: `Verse ${chapter}.${v.verse}`,
|
||||
href: `/books/nbs/${chapter}/${v.verse}`,
|
||||
type: "verse",
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/nbs"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Nārada-bhakti-sūtra
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
||||
Chapter {chapter}: {title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
<ChapterViewer
|
||||
initialVerse={verse}
|
||||
initialVerseNum={requestedVerse}
|
||||
verseIndex={verseIndex.map((v) => ({
|
||||
verse: v.verse,
|
||||
verseEnd: v.verseEnd ?? undefined,
|
||||
reference: v.reference,
|
||||
}))}
|
||||
book="nbs"
|
||||
division={null}
|
||||
chapter={parseInt(chapter)}
|
||||
basePath={`/books/nbs/${chapter}`}
|
||||
showBengali={false}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getChapterVerseIndex } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import Link from "next/link";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
|
||||
|
||||
type IntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter } = await params;
|
||||
return buildOgMeta({
|
||||
title: BOOK_NAMES["nbs"],
|
||||
detail: `Chapter ${chapter}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function NbsChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter } = await params;
|
||||
|
||||
// Handle intro segments (intro-introduction)
|
||||
if (chapter.startsWith("intro-")) {
|
||||
const rawSlug = chapter.replace(/^intro-/, "");
|
||||
const filePath = path.join(NBS_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) notFound();
|
||||
|
||||
let segment: IntroSegment;
|
||||
try {
|
||||
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/nbs"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Nārada-bhakti-sūtra
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{segment.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{segment.paragraphs.map((paragraph: string, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle numeric chapters — redirect to first verse
|
||||
const chapterNum = parseInt(chapter);
|
||||
if (isNaN(chapterNum)) notFound();
|
||||
|
||||
// Try DB first
|
||||
const verseIndex = await getChapterVerseIndex("nbs", null, chapterNum);
|
||||
|
||||
// Fallback to local JSON
|
||||
if (verseIndex.length === 0) {
|
||||
const versesDir = path.join(
|
||||
NBS_JSON_DIR,
|
||||
"chapters",
|
||||
String(chapterNum).padStart(2, "0"),
|
||||
"verses",
|
||||
);
|
||||
if (fs.existsSync(versesDir)) {
|
||||
const files = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f: string) => f.endsWith(".json"))
|
||||
.sort();
|
||||
if (files.length > 0) {
|
||||
const first = JSON.parse(
|
||||
fs.readFileSync(path.join(versesDir, files[0]), "utf-8"),
|
||||
);
|
||||
redirect(`/books/nbs/${chapter}/${first.verse}`);
|
||||
}
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
redirect(`/books/nbs/${chapter}/${verseIndex[0].verse}`);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("nbs");
|
||||
}
|
||||
|
||||
type NbsMeta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year?: number;
|
||||
description?: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
type NbsIntroSegment = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
|
||||
|
||||
async function getMetaWithFallback(): Promise<NbsMeta | null> {
|
||||
const meta = await getBookMeta("nbs");
|
||||
if (meta) return meta as unknown as NbsMeta;
|
||||
|
||||
const introPath = path.join(NBS_JSON_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalIntroSegments(): NbsIntroSegment[] {
|
||||
const introDir = path.join(NBS_JSON_DIR, "intro");
|
||||
if (!fs.existsSync(introDir)) return [];
|
||||
|
||||
const results: NbsIntroSegment[] = [];
|
||||
const files = ["introduction.json"];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
results.push(
|
||||
JSON.parse(fs.readFileSync(filePath, "utf-8")) as NbsIntroSegment,
|
||||
);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export default async function NbsPage() {
|
||||
const meta = await getMetaWithFallback();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = getLocalIntroSegments();
|
||||
const introduction = introSegments.find((s) => s.slug === "introduction");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
{meta.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{meta.description}
|
||||
</p>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Front Matter */}
|
||||
{introduction && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/nbs/intro-introduction"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border py-0">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/nbs/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
<ExtractedVerseCount count={ch.totalVerses} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getNodContent,
|
||||
getNodSidebarItems,
|
||||
type NodChapter,
|
||||
type NodSidebarItem,
|
||||
} from "@/lib/nod";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("nod", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["nod"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("nod", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function NodChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getNodContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getNodSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as NodChapter).chapter === "number" &&
|
||||
(content as NodChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as NodChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/nod"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Nectar of Devotion
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<NodSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<NodNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<NodNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NodSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: NodSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
const conclusionItems = items.filter((item) => item.type === "conclusion");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Concluding Words */}
|
||||
{conclusionItems.length > 0 && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{conclusionItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: NodSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/nod/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NodNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: NodSidebarItem | null;
|
||||
next: NodSidebarItem | null;
|
||||
sidebarItems: NodSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/nod/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
const conclusionItems = sidebarItems.filter((i) => i.type === "conclusion");
|
||||
if (chapterItems.length > 0 || conclusionItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: [
|
||||
...chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/nod/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
...conclusionItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/nod/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="The Nectar of Devotion"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/nod/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/nod/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getNodMeta, getNodIntroSegments } from "@/lib/nod";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("nod");
|
||||
}
|
||||
|
||||
const PART_LABELS: Record<number, string> = {
|
||||
1: "Part One",
|
||||
2: "Part Two",
|
||||
3: "Part Three",
|
||||
4: "Part Four",
|
||||
};
|
||||
|
||||
export default async function NodPage() {
|
||||
const meta = await getNodMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getNodIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground italic">
|
||||
{meta.subtitle}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/nod/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parts & Chapters */}
|
||||
{meta.parts.map((part) => {
|
||||
const partChapters = part.chapters
|
||||
.map((num) => meta.chapters.find((ch) => ch.number === num))
|
||||
.filter(Boolean) as { number: number; title: string }[];
|
||||
|
||||
return (
|
||||
<div key={part.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{PART_LABELS[part.number] ?? `Part ${part.number}`}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{partChapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/nod/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Concluding Words */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Conclusion
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/nod/concluding-words"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Concluding Words
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getNoiContent,
|
||||
getNoiSidebarItems,
|
||||
type NoiVerse,
|
||||
type NoiIntroSegment,
|
||||
type NoiSidebarItem,
|
||||
} from "@/lib/noi";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isVerse(content: NoiVerse | NoiIntroSegment): content is NoiVerse {
|
||||
return "text" in content && "devanagari" in content;
|
||||
}
|
||||
|
||||
function isIntroSegment(
|
||||
content: NoiVerse | NoiIntroSegment,
|
||||
): content is NoiIntroSegment {
|
||||
return "paragraphs" in content && "order" in content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ text: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { text: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
|
||||
const content = await getNoiContent(slug);
|
||||
let paragraphs: string[] | undefined;
|
||||
if (content) {
|
||||
if ("devanagari" in content) {
|
||||
// It's a verse
|
||||
paragraphs = [content.translation, ...content.purport];
|
||||
} else if ("paragraphs" in content) {
|
||||
// It's an intro segment
|
||||
paragraphs = content.paragraphs;
|
||||
}
|
||||
}
|
||||
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: "The Nectar of Instruction",
|
||||
detail: `Text ${slug}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
|
||||
return ogForChapter("noi", `Text ${slug}`);
|
||||
}
|
||||
|
||||
export default async function NoiTextPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ text: string }>;
|
||||
}) {
|
||||
const { text: slug } = await params;
|
||||
|
||||
const content = await getNoiContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getNoiSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title for the header
|
||||
const verse = isVerse(content) ? content : null;
|
||||
const intro = isIntroSegment(content) ? content : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/noi"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Nectar of Instruction
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{verse && (
|
||||
<>
|
||||
<span className="text-primary">Text {verse.text}: </span>
|
||||
{verse.title}
|
||||
</>
|
||||
)}
|
||||
{intro && intro.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<NoiSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<NoiNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{verse && <VerseContent verse={verse} />}
|
||||
{intro && <IntroContent intro={intro} />}
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<NoiNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verse content (Devanagari, transliteration, synonyms, translation, purport)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VerseContent({ verse }: { verse: NoiVerse }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8 space-y-8">
|
||||
{/* Devanagari */}
|
||||
<section>
|
||||
<div className="text-xl leading-relaxed text-foreground whitespace-pre-line font-serif text-center">
|
||||
{verse.devanagari}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transliteration */}
|
||||
<section>
|
||||
<div className="text-base text-foreground/80 italic whitespace-pre-line leading-relaxed text-center">
|
||||
{verse.transliteration}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Synonyms */}
|
||||
{verse.synonyms.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Synonyms
|
||||
</h2>
|
||||
<div className="text-base text-foreground leading-relaxed">
|
||||
{verse.synonyms.map((s, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && "; "}
|
||||
<span className="font-medium text-foreground/90">
|
||||
{s.word}
|
||||
</span>
|
||||
{"—"}
|
||||
{s.meanings.join(", ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Translation */}
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Translation
|
||||
</h2>
|
||||
<div
|
||||
className="text-base text-foreground font-medium leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: verse.translation }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Purport */}
|
||||
{verse.purport.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Purport
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{verse.purport.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: p }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segment content (paragraphs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IntroContent({ intro }: { intro: NoiIntroSegment }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{intro.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoiSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: NoiSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Front matter items (front-pages + preface)
|
||||
const frontMatterItems = items.filter(
|
||||
(item) =>
|
||||
item.slug === "intro-front-pages" || item.slug === "intro-preface",
|
||||
);
|
||||
// Texts = 1-11
|
||||
const textItems = items.filter((item) => item.type === "verse");
|
||||
// Appendixes comes last
|
||||
const appendixesItem = items.find((item) => item.slug === "intro-appendixes");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Texts */}
|
||||
{textItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Texts
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{textItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Appendixes */}
|
||||
{appendixesItem && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
<SidebarLink
|
||||
item={appendixesItem}
|
||||
isActive={appendixesItem.slug === currentSlug}
|
||||
/>
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: NoiSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/noi/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoiNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: NoiSidebarItem | null;
|
||||
next: NoiSidebarItem | null;
|
||||
sidebarItems: NoiSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
// Front matter (front-pages + preface)
|
||||
const frontMatterItems = sidebarItems.filter(
|
||||
(i) => i.slug === "intro-front-pages" || i.slug === "intro-preface",
|
||||
);
|
||||
if (frontMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/noi/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Texts (1-11)
|
||||
const textItems = sidebarItems.filter((i) => i.type === "verse");
|
||||
if (textItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Texts",
|
||||
items: textItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/noi/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Back matter (appendixes)
|
||||
const backMatterItems = sidebarItems.filter(
|
||||
(i) => i.slug === "intro-appendixes",
|
||||
);
|
||||
if (backMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Back Matter",
|
||||
items: backMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/noi/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="The Nectar of Instruction"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/noi/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "verse" && prev.text != null
|
||||
? `Text ${prev.text}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/noi/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "verse" && next.text != null
|
||||
? `Text ${next.text}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getNoiMeta, getNoiIntroSegments } from "@/lib/noi";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("noi");
|
||||
}
|
||||
|
||||
export default async function NoiPage() {
|
||||
const meta = await getNoiMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getNoiIntroSegments();
|
||||
|
||||
const frontPages = introSegments.find((s) => s.slug === "front-pages");
|
||||
const preface = introSegments.find((s) => s.slug === "preface");
|
||||
const appendixes = introSegments.find((s) => s.slug === "appendixes");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 italic text-muted-foreground">{meta.subtitle}</p>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{(frontPages || preface) && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontPages && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/noi/intro-front-pages"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{frontPages.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{preface && (
|
||||
<li>
|
||||
<Link
|
||||
href="/books/noi/intro-preface"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{preface.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Texts */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Texts
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.texts.map((t) => (
|
||||
<li key={t.number}>
|
||||
<Link
|
||||
href={`/books/noi/${t.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Text {t.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">{t.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Appendixes */}
|
||||
{appendixes && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Appendixes
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/noi/intro-appendixes"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{appendixes.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getOchkmContent,
|
||||
getOchkmSidebarItems,
|
||||
type OchkmChapter,
|
||||
type OchkmSidebarItem,
|
||||
} from "@/lib/ochkm";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("ochkm", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["ochkm"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("ochkm", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function OchkmChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getOchkmContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getOchkmSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as OchkmChapter).chapter === "number" &&
|
||||
(content as OchkmChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as OchkmChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/ochkm"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← On Chanting the Hare Kṛṣṇa Mantra
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<OchkmSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<OchkmNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<OchkmNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OchkmSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: OchkmSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: OchkmSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ochkm/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OchkmNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: OchkmSidebarItem | null;
|
||||
next: OchkmSidebarItem | null;
|
||||
sidebarItems: OchkmSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ochkm/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ochkm/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="On Chanting the Hare Kṛṣṇa Mantra"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ochkm/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ochkm/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getOchkmMeta, getOchkmIntroSegments } from "@/lib/ochkm";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("ochkm");
|
||||
}
|
||||
|
||||
export default async function OchkmPage() {
|
||||
const meta = await getOchkmMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getOchkmIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ochkm/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/ochkm/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getOwkContent,
|
||||
getOwkSidebarItems,
|
||||
type OwkChapter,
|
||||
type OwkSidebarItem,
|
||||
} from "@/lib/owk";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("owk", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["owk"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("owk", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function OwkChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getOwkContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getOwkSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as OwkChapter).chapter === "number" &&
|
||||
(content as OwkChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as OwkChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/owk"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← On the Way to Kṛṣṇa
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<OwkSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<OwkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<OwkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OwkSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: OwkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: OwkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/owk/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OwkNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: OwkSidebarItem | null;
|
||||
next: OwkSidebarItem | null;
|
||||
sidebarItems: OwkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/owk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/owk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="On the Way to Kṛṣṇa"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/owk/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/owk/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getOwkMeta, getOwkIntroSegments } from "@/lib/owk";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("owk");
|
||||
}
|
||||
|
||||
export default async function OwkPage() {
|
||||
const meta = await getOwkMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getOwkIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/owk/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/owk/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { getAllBooks, type BookMeta } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BooksGrid, type BookInfo } from "./books-grid";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return buildOgMeta({
|
||||
title: "Books",
|
||||
detail: "Books by Śrīla Prabhupāda",
|
||||
quote:
|
||||
"Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, and 30+ other books with Devanagari, transliteration, synonyms, translations & purports.",
|
||||
});
|
||||
}
|
||||
|
||||
type ChapterEntry = { totalVerses: number };
|
||||
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
|
||||
type LilaEntry = { totalChapters: number; chapters: ChapterEntry[] };
|
||||
|
||||
interface ChapterBookMeta extends BookMeta {
|
||||
totalChapters: number;
|
||||
chapters: ChapterEntry[];
|
||||
}
|
||||
|
||||
interface SbMeta extends BookMeta {
|
||||
totalCantos: number;
|
||||
cantos: CantoEntry[];
|
||||
}
|
||||
|
||||
interface CcMeta extends BookMeta {
|
||||
totalLilas: number;
|
||||
lilas: LilaEntry[];
|
||||
}
|
||||
|
||||
function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
||||
const books: BookInfo[] = [];
|
||||
|
||||
for (const data of allBooks) {
|
||||
const slug = data.book;
|
||||
const title = data.title as string;
|
||||
const author = data.author as string;
|
||||
|
||||
if (slug === "bg") {
|
||||
const bg = data as ChapterBookMeta;
|
||||
const totalVerses = bg.chapters.reduce(
|
||||
(sum, ch) => sum + ch.totalVerses,
|
||||
0,
|
||||
);
|
||||
books.push({
|
||||
slug,
|
||||
title,
|
||||
author,
|
||||
description: `${bg.totalChapters} chapters`,
|
||||
contentDescription:
|
||||
"The essence of Vedic knowledge — Lord Kṛṣṇa's teachings to Arjuna on the battlefield of Kurukṣetra.",
|
||||
totalVerses,
|
||||
href: "/books/bg",
|
||||
});
|
||||
} else if (slug === "sb1962") {
|
||||
const sb62 = data as ChapterBookMeta;
|
||||
const totalVerses = sb62.chapters.reduce(
|
||||
(sum, ch) => sum + ch.totalVerses,
|
||||
0,
|
||||
);
|
||||
books.push({
|
||||
slug,
|
||||
title,
|
||||
author,
|
||||
description: `${sb62.totalChapters} chapters · First Canto`,
|
||||
contentDescription:
|
||||
(data as { description?: string }).description ||
|
||||
"The original 1962 Indian edition of the First Canto.",
|
||||
totalVerses,
|
||||
href: "/books/sb1962",
|
||||
});
|
||||
} else if (slug === "sb") {
|
||||
const sb = data as SbMeta;
|
||||
const totalChapters = sb.cantos.reduce(
|
||||
(sum, c) => sum + c.totalChapters,
|
||||
0,
|
||||
);
|
||||
const totalVerses = sb.cantos.reduce(
|
||||
(sum, c) => sum + c.chapters.reduce((s, ch) => s + ch.totalVerses, 0),
|
||||
0,
|
||||
);
|
||||
books.push({
|
||||
slug,
|
||||
title,
|
||||
author,
|
||||
description: `${sb.totalCantos} cantos, ${totalChapters} chapters`,
|
||||
contentDescription:
|
||||
"The ripened fruit of Vedic literature — the science of God in 18,000 verses.",
|
||||
totalVerses,
|
||||
href: "/books/sb",
|
||||
});
|
||||
} else if (slug === "cc") {
|
||||
const cc = data as CcMeta;
|
||||
const totalChapters = cc.lilas.reduce(
|
||||
(sum, l) => sum + l.totalChapters,
|
||||
0,
|
||||
);
|
||||
const totalVerses = cc.lilas.reduce(
|
||||
(sum, l) => sum + l.chapters.reduce((s, ch) => s + ch.totalVerses, 0),
|
||||
0,
|
||||
);
|
||||
books.push({
|
||||
slug,
|
||||
title,
|
||||
author,
|
||||
description: `${cc.totalLilas} līlās, ${totalChapters} chapters`,
|
||||
contentDescription:
|
||||
"The life and teachings of Lord Caitanya Mahāprabhu — the most complete theistic science.",
|
||||
totalVerses,
|
||||
href: "/books/cc",
|
||||
});
|
||||
} else {
|
||||
// Generic handler for all other books — pulls description from metadata
|
||||
const metaDesc = (data as { description?: string }).description || "";
|
||||
const totalChapters = (data as { totalChapters?: number }).totalChapters;
|
||||
const totalMantras = (data as { totalMantras?: number }).totalMantras;
|
||||
const totalTexts = (data as { totalTexts?: number }).totalTexts;
|
||||
const parts = (data as { parts?: unknown[] }).parts;
|
||||
const volumes = (data as { volumes?: unknown[] }).volumes;
|
||||
|
||||
let badge = "";
|
||||
if (totalMantras) badge = `${totalMantras} mantras`;
|
||||
else if (totalTexts) badge = `${totalTexts} texts`;
|
||||
else if (totalChapters && volumes)
|
||||
badge = `${totalChapters} chapters · ${volumes.length} volumes`;
|
||||
else if (totalChapters && parts)
|
||||
badge = `${totalChapters} chapters · ${parts.length} parts`;
|
||||
else if (totalChapters) badge = `${totalChapters} chapters`;
|
||||
|
||||
books.push({
|
||||
slug,
|
||||
title,
|
||||
author,
|
||||
description: badge,
|
||||
contentDescription: metaDesc,
|
||||
totalVerses: 0,
|
||||
href: `/books/${slug}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
export default async function BooksPage() {
|
||||
const allBooks = await getAllBooks();
|
||||
const books = buildBookList(allBooks);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Books</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Browse the books and teachings of Śrīla Prabhupāda
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BooksGrid books={books} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getPkContent,
|
||||
getPkSidebarItems,
|
||||
type PkChapter,
|
||||
type PkSidebarItem,
|
||||
} from "@/lib/pk";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("pk", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["pk"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("pk", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function PkChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getPkContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getPkSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
const chapterNum = (content as PkChapter).chapter;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/pk"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Prayers of King Kulaśekhara
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Verse {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<PkSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<PkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<PkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PkSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: PkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Chapters (Prayers) */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Prayers
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: PkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/pk/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PkNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: PkSidebarItem | null;
|
||||
next: PkSidebarItem | null;
|
||||
sidebarItems: PkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Prayers",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/pk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Prayers of King Kulaśekhara"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/pk/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.chapter != null ? `Prayer ${prev.chapter}` : prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/pk/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.chapter != null ? `Prayer ${next.chapter}` : next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPkMeta } from "@/lib/pk";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("pk");
|
||||
}
|
||||
|
||||
export default async function PkPage() {
|
||||
const meta = await getPkMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Chapters (Prayers) */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Prayers
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/pk/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Verse {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getPoyContent,
|
||||
getPoySidebarItems,
|
||||
type PoyChapter,
|
||||
type PoySidebarItem,
|
||||
} from "@/lib/poy";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("poy", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["poy"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("poy", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function PoyChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getPoyContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getPoySidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as PoyChapter).chapter === "number" &&
|
||||
(content as PoyChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as PoyChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/poy"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Perfection of Yoga
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<PoySidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<PoyNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<PoyNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PoySidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: PoySidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: PoySidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/poy/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PoyNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: PoySidebarItem | null;
|
||||
next: PoySidebarItem | null;
|
||||
sidebarItems: PoySidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/poy/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/poy/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="The Perfection of Yoga"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/poy/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/poy/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPoyMeta, getPoyIntroSegments } from "@/lib/poy";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("poy");
|
||||
}
|
||||
|
||||
export default async function PoyPage() {
|
||||
const meta = await getPoyMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getPoyIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/poy/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/poy/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getPqpaContent,
|
||||
getPqpaSidebarItems,
|
||||
type PqpaChapter,
|
||||
type PqpaSidebarItem,
|
||||
} from "@/lib/pqpa";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("pqpa", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["pqpa"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("pqpa", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function PqpaChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getPqpaContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getPqpaSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as PqpaChapter).chapter === "number" &&
|
||||
(content as PqpaChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as PqpaChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/pqpa"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Perfect Questions Perfect Answers
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<PqpaSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<PqpaNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<PqpaNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PqpaSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: PqpaSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items into groups for visual sections
|
||||
// Front matter: intro-segment items before chapters
|
||||
// Chapters: chapter items
|
||||
// Conclusion: conclusion items
|
||||
// Back matter: intro-segment items after conclusion (about-the-author)
|
||||
|
||||
const frontMatterItems: PqpaSidebarItem[] = [];
|
||||
const chapterItems: PqpaSidebarItem[] = [];
|
||||
const conclusionItems: PqpaSidebarItem[] = [];
|
||||
const backMatterItems: PqpaSidebarItem[] = [];
|
||||
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (item.type === "conclusion") {
|
||||
pastChapters = true;
|
||||
conclusionItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Concluding Words */}
|
||||
{conclusionItems.length > 0 && (
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{conclusionItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* About the Author */}
|
||||
{backMatterItems.length > 0 && (
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{backMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: PqpaSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/pqpa/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PqpaNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: PqpaSidebarItem | null;
|
||||
next: PqpaSidebarItem | null;
|
||||
sidebarItems: PqpaSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const frontMatterItems: PqpaSidebarItem[] = [];
|
||||
const chapterItems: PqpaSidebarItem[] = [];
|
||||
const conclusionItems: PqpaSidebarItem[] = [];
|
||||
const backMatterItems: PqpaSidebarItem[] = [];
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of sidebarItems) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (item.type === "conclusion") {
|
||||
pastChapters = true;
|
||||
conclusionItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/pqpa/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: [
|
||||
...chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/pqpa/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
...conclusionItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/pqpa/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (backMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Back Matter",
|
||||
items: backMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/pqpa/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Perfect Questions Perfect Answers"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/pqpa/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/pqpa/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPqpaMeta, getPqpaIntroSegments } from "@/lib/pqpa";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("pqpa");
|
||||
}
|
||||
|
||||
export default async function PqpaPage() {
|
||||
const meta = await getPqpaMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getPqpaIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (order < 99) and back matter (order >= 99)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 99);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 99);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/pqpa/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/pqpa/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Concluding Words */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Conclusion
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li>
|
||||
<Link
|
||||
href="/books/pqpa/concluding-words"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Concluding Words
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* About the Author */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/pqpa/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getRvContent,
|
||||
getRvSidebarItems,
|
||||
type RvChapter,
|
||||
type RvSidebarItem,
|
||||
} from "@/lib/rv";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("rv", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["rv"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("rv", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function RvChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getRvContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getRvSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as RvChapter).chapter === "number" &&
|
||||
(content as RvChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as RvChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/rv"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Rāja-vidyā: The King of Knowledge
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<RvSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<RvNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<RvNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RvSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: RvSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: RvSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/rv/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RvNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: RvSidebarItem | null;
|
||||
next: RvSidebarItem | null;
|
||||
sidebarItems: RvSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/rv/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/rv/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Rāja-vidyā: The King of Knowledge"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/rv/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/rv/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRvMeta, getRvIntroSegments } from "@/lib/rv";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("rv");
|
||||
}
|
||||
|
||||
export default async function RvPage() {
|
||||
const meta = await getRvMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getRvIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/rv/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/rv/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
|
||||
type ChapterInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalVerses: number;
|
||||
};
|
||||
|
||||
type CantoInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalChapters: number;
|
||||
chapters: ChapterInfo[];
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ canto: string; chapter: string; verse: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { canto, chapter, verse } = await params;
|
||||
const sp = await searchParams;
|
||||
const v = await getSingleVerse(
|
||||
"sb",
|
||||
canto,
|
||||
parseInt(chapter),
|
||||
parseInt(verse),
|
||||
);
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: "Śrīmad-Bhāgavatam",
|
||||
detail: `Canto ${canto}, Chapter ${chapter}, Verse ${verse}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForVerse("sb", {
|
||||
division: canto,
|
||||
chapter: parseInt(chapter),
|
||||
verse: parseInt(verse),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function SbVersePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ canto: string; chapter: string; verse: string }>;
|
||||
}) {
|
||||
const { canto, chapter, verse } = await params;
|
||||
|
||||
const meta = await getBookMeta("sb");
|
||||
if (!meta) notFound();
|
||||
|
||||
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
|
||||
const cantoInfo = cantos.find((c) => c.number === parseInt(canto));
|
||||
if (!cantoInfo) notFound();
|
||||
|
||||
const chapterInfo = cantoInfo.chapters.find(
|
||||
(ch) => ch.number === parseInt(chapter),
|
||||
);
|
||||
if (!chapterInfo) notFound();
|
||||
|
||||
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
|
||||
if (verseIndex.length === 0) notFound();
|
||||
|
||||
const requestedVerse = parseInt(verse);
|
||||
const verseData = await getSingleVerse(
|
||||
"sb",
|
||||
canto,
|
||||
parseInt(chapter),
|
||||
requestedVerse,
|
||||
);
|
||||
if (!verseData) notFound();
|
||||
|
||||
const mobileNavGroups: MobileNavGroup[] = [
|
||||
{
|
||||
title: "Verses",
|
||||
items: verseIndex.map((v) => ({
|
||||
slug: String(v.verse),
|
||||
label: v.verseEnd
|
||||
? `Verse ${canto}.${chapter}.${v.verse}–${v.verseEnd}`
|
||||
: `Verse ${canto}.${chapter}.${v.verse}`,
|
||||
href: `/books/sb/${canto}/${chapter}/${v.verse}`,
|
||||
type: "verse",
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href={`/books/sb/${canto}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Canto {cantoInfo.number}: {cantoInfo.title}
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
||||
Chapter {chapter}: {chapterInfo.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
<ChapterViewer
|
||||
initialVerse={verseData}
|
||||
initialVerseNum={requestedVerse}
|
||||
verseIndex={verseIndex.map((v) => ({
|
||||
verse: v.verse,
|
||||
verseEnd: v.verseEnd ?? undefined,
|
||||
reference: v.reference,
|
||||
}))}
|
||||
book="sb"
|
||||
division={canto}
|
||||
chapter={parseInt(chapter)}
|
||||
showBengali={false}
|
||||
basePath={`/books/sb/${canto}/${chapter}`}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getChapterVerseIndex } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ canto: string; chapter: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { canto, chapter } = await params;
|
||||
return buildOgMeta({
|
||||
title: "Śrīmad-Bhāgavatam",
|
||||
detail: `Canto ${canto}, Chapter ${chapter}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function SbChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ canto: string; chapter: string }>;
|
||||
}) {
|
||||
const { canto, chapter } = await params;
|
||||
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
|
||||
if (verseIndex.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
redirect(`/books/sb/${canto}/${chapter}/${verseIndex[0].verse}`);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ExtractedVerseCount } from "../../extracted-verse-count";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ canto: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { canto } = await params;
|
||||
const title = BOOK_NAMES["sb"] || "Śrīmad-Bhāgavatam";
|
||||
return buildOgMeta({ title, detail: `Canto ${canto}` });
|
||||
}
|
||||
|
||||
type ChapterInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalVerses: number;
|
||||
};
|
||||
|
||||
type CantoInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalChapters: number;
|
||||
chapters: ChapterInfo[];
|
||||
};
|
||||
|
||||
export default async function CantoPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ canto: string }>;
|
||||
}) {
|
||||
const { canto } = await params;
|
||||
const cantoNum = parseInt(canto);
|
||||
const meta = await getBookMeta("sb");
|
||||
if (!meta) notFound();
|
||||
|
||||
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
|
||||
const cantoInfo = cantos.find((c) => c.number === cantoNum);
|
||||
if (!cantoInfo) notFound();
|
||||
|
||||
const totalVerses = cantoInfo.chapters.reduce(
|
||||
(sum, ch) => sum + ch.totalVerses,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books/sb"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Śrīmad-Bhāgavatam
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
Canto {cantoInfo.number}: {cantoInfo.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{cantoInfo.totalChapters} chapters ·{" "}
|
||||
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
|
||||
</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{cantoInfo.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/sb/${cantoNum}/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">{ch.title}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
<ExtractedVerseCount count={ch.totalVerses} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("sb");
|
||||
}
|
||||
|
||||
type ChapterInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalVerses: number;
|
||||
};
|
||||
|
||||
type CantoInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalChapters: number;
|
||||
chapters: ChapterInfo[];
|
||||
};
|
||||
|
||||
export default async function SbPage() {
|
||||
const meta = await getBookMeta("sb");
|
||||
if (!meta) notFound();
|
||||
|
||||
const intro = meta as {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
totalCantos: number;
|
||||
cantos: CantoInfo[];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{intro.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{intro.author}</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{intro.cantos.map((canto) => {
|
||||
const totalVerses = canto.chapters.reduce(
|
||||
(sum, ch) => sum + ch.totalVerses,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<li key={canto.number}>
|
||||
<Link
|
||||
href={`/books/sb/${canto.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-1">
|
||||
Canto {canto.number}
|
||||
</Badge>
|
||||
<p className="font-medium text-foreground">
|
||||
{canto.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{canto.totalChapters} chapters ·{" "}
|
||||
<ExtractedVerseCount
|
||||
count={totalVerses}
|
||||
label="extracted verses"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Sb1962Meta = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter, verse } = await params;
|
||||
const sp = await searchParams;
|
||||
const v = await getSingleVerse(
|
||||
"sb1962",
|
||||
null,
|
||||
parseInt(chapter),
|
||||
parseInt(verse),
|
||||
);
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: "Śrīmad-Bhāgavatam (1962)",
|
||||
detail: `Chapter ${chapter}, Verse ${verse}`,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForVerse("sb1962", {
|
||||
chapter: parseInt(chapter),
|
||||
verse: parseInt(verse),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function VersePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string; verse: string }>;
|
||||
}) {
|
||||
const { chapter, verse: verseParam } = await params;
|
||||
|
||||
const meta = await getBookMeta("sb1962");
|
||||
if (!meta) notFound();
|
||||
|
||||
const sb1962Meta = meta as unknown as Sb1962Meta;
|
||||
const chapterInfo = sb1962Meta.chapters?.find(
|
||||
(c) => c.number === parseInt(chapter),
|
||||
);
|
||||
if (!chapterInfo?.title) notFound();
|
||||
|
||||
const title: string = chapterInfo.title;
|
||||
|
||||
const verseIndex = await getChapterVerseIndex(
|
||||
"sb1962",
|
||||
null,
|
||||
parseInt(chapter),
|
||||
);
|
||||
if (verseIndex.length === 0) notFound();
|
||||
|
||||
const requestedVerse = parseInt(verseParam);
|
||||
const verse = await getSingleVerse(
|
||||
"sb1962",
|
||||
null,
|
||||
parseInt(chapter),
|
||||
requestedVerse,
|
||||
);
|
||||
if (!verse) notFound();
|
||||
|
||||
const mobileNavGroups: MobileNavGroup[] = [
|
||||
{
|
||||
title: "Verses",
|
||||
items: verseIndex.map((v) => ({
|
||||
slug: String(v.verse),
|
||||
label: v.verseEnd
|
||||
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
|
||||
: `Verse ${chapter}.${v.verse}`,
|
||||
href: `/books/sb1962/${chapter}/${v.verse}`,
|
||||
type: "verse",
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/sb1962"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Śrīmad-Bhāgavatam (1962)
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
||||
Chapter {chapter}: {title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
<ChapterViewer
|
||||
initialVerse={verse}
|
||||
initialVerseNum={requestedVerse}
|
||||
verseIndex={verseIndex.map((v) => ({
|
||||
verse: v.verse,
|
||||
verseEnd: v.verseEnd ?? undefined,
|
||||
reference: v.reference,
|
||||
}))}
|
||||
book="sb1962"
|
||||
division={null}
|
||||
chapter={parseInt(chapter)}
|
||||
basePath={`/books/sb1962/${chapter}`}
|
||||
showBengali={false}
|
||||
mobileNavGroups={mobileNavGroups}
|
||||
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getChapterVerseIndex } from "@/lib/api";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter } = await params;
|
||||
return buildOgMeta({
|
||||
title: BOOK_NAMES["sb1962"],
|
||||
detail: `Chapter ${chapter}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter } = await params;
|
||||
const verseIndex = await getChapterVerseIndex(
|
||||
"sb1962",
|
||||
null,
|
||||
parseInt(chapter),
|
||||
);
|
||||
if (verseIndex.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
redirect(`/books/sb1962/${chapter}/${verseIndex[0].verse}`);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("sb1962");
|
||||
}
|
||||
|
||||
type ChapterInfo = {
|
||||
number: number;
|
||||
title: string;
|
||||
totalVerses: number;
|
||||
};
|
||||
|
||||
export default async function Sb1962Page() {
|
||||
const meta = await getBookMeta("sb1962");
|
||||
if (!meta) notFound();
|
||||
|
||||
const intro = meta as unknown as {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
description?: string;
|
||||
totalChapters: number;
|
||||
chapters: ChapterInfo[];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{intro.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{intro.author}</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Card className="divide-y divide-border py-0">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{intro.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/sb1962/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">{ch.title}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
<ExtractedVerseCount count={ch.totalVerses} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getSsrContent,
|
||||
getSsrSidebarItems,
|
||||
type SsrChapter,
|
||||
type SsrSidebarItem,
|
||||
} from "@/lib/ssr";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("ssr", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["ssr"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("ssr", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function SsrChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getSsrContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getSsrSidebarItems();
|
||||
|
||||
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
|
||||
const navigableItems = sidebarItems.filter((item) => item.type !== "parent");
|
||||
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < navigableItems.length - 1
|
||||
? navigableItems[currentIdx + 1]
|
||||
: null;
|
||||
|
||||
// Determine if this is a sub-chapter (has parentChapter and is type "chapter"/"sub-chapter")
|
||||
const isSubChapter =
|
||||
"parentChapter" in content &&
|
||||
typeof (content as SsrChapter).parentChapter === "number";
|
||||
const parentChapterNum = isSubChapter
|
||||
? (content as SsrChapter).parentChapter
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/ssr"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← The Science of Self-Realization
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{parentChapterNum != null && (
|
||||
<span className="text-primary">Chapter {parentChapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<SsrSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<SsrNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<SsrNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SsrSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: SsrSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items by section
|
||||
const frontMatter = items.filter(
|
||||
(i) => i.type === "intro-segment" && i.slug !== "intro-about-the-author",
|
||||
);
|
||||
const parents = items.filter((i) => i.type === "parent");
|
||||
const subChapters = items.filter((i) => i.type === "sub-chapter");
|
||||
const backMatter = items.filter((i) => i.slug === "intro-about-the-author");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatter.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters — grouped by parent */}
|
||||
{parents.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{parents.map((parent) => (
|
||||
<div key={parent.slug}>
|
||||
{/* Parent heading — not a link, just a bold label */}
|
||||
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
|
||||
{parent.label}
|
||||
</div>
|
||||
{/* Sub-chapters indented */}
|
||||
{subChapters
|
||||
.filter((sub) => sub.parentChapter === parent.parentChapter)
|
||||
.map((sub) => {
|
||||
const isActive = sub.slug === currentSlug;
|
||||
return (
|
||||
<Link
|
||||
key={sub.slug}
|
||||
href={`/books/ssr/${sub.slug}`}
|
||||
className={cn(
|
||||
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={sub.label}
|
||||
>
|
||||
{sub.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatter.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Back Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{backMatter.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: SsrSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ssr/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SsrNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: SsrSidebarItem | null;
|
||||
next: SsrSidebarItem | null;
|
||||
sidebarItems: SsrSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const frontMatter = sidebarItems.filter(
|
||||
(i) => i.type === "intro-segment" && i.slug !== "intro-about-the-author",
|
||||
);
|
||||
if (frontMatter.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatter.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ssr/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter(
|
||||
(i) => i.type === "parent" || i.type === "sub-chapter",
|
||||
);
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ssr/${i.slug}`,
|
||||
type: i.type,
|
||||
parentChapter: i.parentChapter,
|
||||
isParent: i.type === "parent",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const backMatter = sidebarItems.filter(
|
||||
(i) => i.slug === "intro-about-the-author",
|
||||
);
|
||||
if (backMatter.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Back Matter",
|
||||
items: backMatter.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ssr/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="The Science of Self-Realization"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ssr/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ssr/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getSsrMeta, getSsrIntroSegments } from "@/lib/ssr";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("ssr");
|
||||
}
|
||||
|
||||
export default async function SsrPage() {
|
||||
const meta = await getSsrMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getSsrIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (before chapters) and back matter (after)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 99);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 99);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ssr/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters — hierarchical parent → sub-chapter structure */}
|
||||
{meta.chapters.map((ch) => (
|
||||
<div key={ch.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{ch.number}. {ch.title}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{ch.subChapters.map((sub) => (
|
||||
<li key={sub.slug}>
|
||||
<Link
|
||||
href={`/books/ssr/${sub.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{sub.title}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Back Matter (About the Author) */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/ssr/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getSvaContent,
|
||||
getSvaSidebarItems,
|
||||
getNavigableItems,
|
||||
type SvaChapter,
|
||||
type SvaSidebarItem,
|
||||
} from "@/lib/sva";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("sva", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["sva"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("sva", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function SvaChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getSvaContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getSvaSidebarItems();
|
||||
|
||||
// Build a flat list of navigable items (skip "parent" type — they're labels, not pages)
|
||||
const navigableItems = getNavigableItems(sidebarItems);
|
||||
const currentIdx = navigableItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? navigableItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < navigableItems.length - 1
|
||||
? navigableItems[currentIdx + 1]
|
||||
: null;
|
||||
|
||||
// Determine if this is a sub-chapter (has parentChapter)
|
||||
const isSubChapter =
|
||||
"parentChapter" in content &&
|
||||
typeof (content as SvaChapter).parentChapter === "number";
|
||||
const parentChapterNum = isSubChapter
|
||||
? (content as SvaChapter).parentChapter
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/sva"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Songs of the Vaiṣṇava Ācāryas
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{parentChapterNum != null && (
|
||||
<span className="text-primary">Part {parentChapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<SvaSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<SvaNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<SvaNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SvaSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: SvaSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items by section
|
||||
const frontMatter = items.filter((i) => i.type === "intro-segment");
|
||||
const parents = items.filter((i) => i.type === "parent");
|
||||
const subChapters = items.filter((i) => i.type === "sub-chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatter.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Parts — grouped by parent */}
|
||||
{parents.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Songs
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{parents.map((parent) => (
|
||||
<div key={parent.slug}>
|
||||
{/* Parent heading — not a link, just a bold label */}
|
||||
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-3 first:mt-0">
|
||||
{parent.label}
|
||||
</div>
|
||||
{/* Sub-chapters (songs) indented */}
|
||||
{subChapters
|
||||
.filter((sub) => sub.parentChapter === parent.parentChapter)
|
||||
.map((sub) => {
|
||||
const isActive = sub.slug === currentSlug;
|
||||
return (
|
||||
<Link
|
||||
key={sub.slug}
|
||||
href={`/books/sva/${sub.slug}`}
|
||||
className={cn(
|
||||
"pl-4 px-2 py-1.5 rounded-md text-sm transition-colors truncate block",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={sub.label}
|
||||
>
|
||||
{sub.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: SvaSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/sva/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SvaNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: SvaSidebarItem | null;
|
||||
next: SvaSidebarItem | null;
|
||||
sidebarItems: SvaSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const frontMatter = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (frontMatter.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatter.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/sva/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter(
|
||||
(i) => i.type === "parent" || i.type === "sub-chapter",
|
||||
);
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Songs",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/sva/${i.slug}`,
|
||||
type: i.type,
|
||||
parentChapter: i.parentChapter,
|
||||
isParent: i.type === "parent",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Songs of the Vaiṣṇava Ācāryas"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/sva/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/sva/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getSvaMeta, getSvaIntroSegments } from "@/lib/sva";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("sva");
|
||||
}
|
||||
|
||||
export default async function SvaPage() {
|
||||
const meta = await getSvaMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getSvaIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (before chapters) and back matter (after)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 99);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 99);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parts — hierarchical parent → song (sub-chapter) structure */}
|
||||
{meta.chapters.map((ch) => (
|
||||
<div key={ch.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Part {ch.number}: {ch.title}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{ch.subChapters.map((sub) => (
|
||||
<li key={sub.slug}>
|
||||
<Link
|
||||
href={`/books/sva/${sub.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{sub.title}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getTlcContent,
|
||||
getTlcSidebarItems,
|
||||
type TlcChapter,
|
||||
type TlcSidebarItem,
|
||||
} from "@/lib/tlc";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("tlc", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["tlc"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("tlc", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function TlcChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getTlcContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getTlcSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as TlcChapter).chapter === "number" &&
|
||||
(content as TlcChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as TlcChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/tlc"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Teachings of Lord Caitanya
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<TlcSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<TlcNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<TlcNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TlcSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: TlcSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
// Group chapters by part (if parts exist)
|
||||
const hasParts = chapterItems.some((item) => item.partNumber != null);
|
||||
|
||||
const partGroups: {
|
||||
partNumber: number;
|
||||
partTitle: string;
|
||||
chapters: TlcSidebarItem[];
|
||||
}[] = [];
|
||||
if (hasParts) {
|
||||
const partMap = new Map<
|
||||
number,
|
||||
{ partTitle: string; chapters: TlcSidebarItem[] }
|
||||
>();
|
||||
for (const item of chapterItems) {
|
||||
const pn = item.partNumber ?? 0;
|
||||
const pt = item.partTitle ?? `Part ${pn}`;
|
||||
if (!partMap.has(pn)) partMap.set(pn, { partTitle: pt, chapters: [] });
|
||||
partMap.get(pn)!.chapters.push(item);
|
||||
}
|
||||
for (const [partNumber, { partTitle, chapters }] of partMap) {
|
||||
partGroups.push({ partNumber, partTitle, chapters });
|
||||
}
|
||||
partGroups.sort((a, b) => a.partNumber - b.partNumber);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters — grouped by part if available */}
|
||||
{hasParts
|
||||
? partGroups.map((group) => (
|
||||
<div key={group.partNumber} className="mb-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
{group.partTitle}
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{group.chapters.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
))
|
||||
: chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: TlcSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/tlc/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TlcNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: TlcSidebarItem | null;
|
||||
next: TlcSidebarItem | null;
|
||||
sidebarItems: TlcSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/tlc/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/tlc/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Teachings of Lord Caitanya"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/tlc/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/tlc/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTlcMeta, getTlcIntroSegments } from "@/lib/tlc";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("tlc");
|
||||
}
|
||||
|
||||
const PART_LABELS: Record<number, string> = {
|
||||
1: "Part I",
|
||||
2: "Part II",
|
||||
};
|
||||
|
||||
export default async function TlcPage() {
|
||||
const meta = await getTlcMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getTlcIntroSegments();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground italic">
|
||||
{meta.subtitle}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{introSegments && introSegments.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{introSegments.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/tlc/${seg.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parts & Chapters */}
|
||||
{meta.parts.map((part) => {
|
||||
const partChapters = part.chapters
|
||||
.map((num) => meta.chapters.find((ch) => ch.number === num))
|
||||
.filter(Boolean) as { number: number; title: string }[];
|
||||
|
||||
return (
|
||||
<div key={part.number}>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
{PART_LABELS[part.number] ?? `Part ${part.number}`}
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{partChapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/tlc/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getTlkContent,
|
||||
getTlkSidebarItems,
|
||||
type TlkChapter,
|
||||
type TlkSidebarItem,
|
||||
} from "@/lib/tlk";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("tlk", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["tlk"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("tlk", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function TlkChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getTlkContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getTlkSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as TlkChapter).chapter === "number" &&
|
||||
(content as TlkChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as TlkChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/tlk"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Teachings of Lord Kapila
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<TlkSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<TlkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<TlkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TlkSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: TlkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items into groups for visual sections
|
||||
// Front matter: intro-segment items before chapters
|
||||
// Chapters: chapter items
|
||||
// Back matter: intro-segment items after chapters
|
||||
|
||||
const frontMatterItems: TlkSidebarItem[] = [];
|
||||
const chapterItems: TlkSidebarItem[] = [];
|
||||
const backMatterItems: TlkSidebarItem[] = [];
|
||||
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Back Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{backMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: TlkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/tlk/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TlkNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: TlkSidebarItem | null;
|
||||
next: TlkSidebarItem | null;
|
||||
sidebarItems: TlkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const frontMatterItems: TlkSidebarItem[] = [];
|
||||
const chapterItems: TlkSidebarItem[] = [];
|
||||
const backMatterItems: TlkSidebarItem[] = [];
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of sidebarItems) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/tlk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/tlk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (backMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Back Matter",
|
||||
items: backMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/tlk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Teachings of Lord Kapila"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/tlk/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/tlk/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTlkMeta, getTlkIntroSegments } from "@/lib/tlk";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("tlk");
|
||||
}
|
||||
|
||||
export default async function TlkPage() {
|
||||
const meta = await getTlkMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getTlkIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (order < 50) and back matter (order >= 50)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 50);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 50);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/tlk/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getTqkContent,
|
||||
getTqkSidebarItems,
|
||||
type TqkChapter,
|
||||
type TqkSidebarItem,
|
||||
} from "@/lib/tqk";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("tqk", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["tqk"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("tqk", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function TqkChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getTqkContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getTqkSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as TqkChapter).chapter === "number" &&
|
||||
(content as TqkChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as TqkChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/tqk"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Teachings of Queen Kuntī
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<TqkSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<TqkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<TqkNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TqkSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: TqkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
// Split items into groups for visual sections
|
||||
// Front matter: intro-segment items before chapters
|
||||
// Chapters: chapter items
|
||||
// Back matter: intro-segment items after chapters
|
||||
|
||||
const frontMatterItems: TqkSidebarItem[] = [];
|
||||
const chapterItems: TqkSidebarItem[] = [];
|
||||
const backMatterItems: TqkSidebarItem[] = [];
|
||||
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{frontMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{frontMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Back Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{backMatterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: TqkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/tqk/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TqkNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: TqkSidebarItem | null;
|
||||
next: TqkSidebarItem | null;
|
||||
sidebarItems: TqkSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const frontMatterItems: TqkSidebarItem[] = [];
|
||||
const chapterItems: TqkSidebarItem[] = [];
|
||||
const backMatterItems: TqkSidebarItem[] = [];
|
||||
let pastChapters = false;
|
||||
|
||||
for (const item of sidebarItems) {
|
||||
if (item.type === "chapter") {
|
||||
pastChapters = true;
|
||||
chapterItems.push(item);
|
||||
} else if (!pastChapters) {
|
||||
frontMatterItems.push(item);
|
||||
} else {
|
||||
backMatterItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: frontMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/tqk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/tqk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (backMatterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Back Matter",
|
||||
items: backMatterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/tqk/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Teachings of Queen Kuntī"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/tqk/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/tqk/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTqkMeta, getTqkIntroSegments } from "@/lib/tqk";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("tqk");
|
||||
}
|
||||
|
||||
export default async function TqkPage() {
|
||||
const meta = await getTqkMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
const introSegments = await getTqkIntroSegments();
|
||||
|
||||
// Split intro segments into front matter (order < 50) and back matter (order >= 50)
|
||||
const frontMatter = introSegments.filter((seg) => seg.order < 50);
|
||||
const backMatter = introSegments.filter((seg) => seg.order >= 50);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Front Matter */}
|
||||
{frontMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Front Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{frontMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/tqk/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Back Matter */}
|
||||
{backMatter.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Back Matter
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{backMatter.map((seg) => (
|
||||
<li key={seg.slug}>
|
||||
<Link
|
||||
href={`/books/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{seg.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getTtpContent,
|
||||
getTtpSidebarItems,
|
||||
type TtpChapter,
|
||||
type TtpSidebarItem,
|
||||
} from "@/lib/ttp";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("ttp", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["ttp"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("ttp", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function TtpChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getTtpContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getTtpSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
// Build the page title
|
||||
const isChapter =
|
||||
"chapter" in content &&
|
||||
typeof (content as TtpChapter).chapter === "number" &&
|
||||
(content as TtpChapter).chapter !== null;
|
||||
const chapterNum = isChapter ? (content as TtpChapter).chapter : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/ttp"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Transcendental Teachings of Prahlāda Mahārāja
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{chapterNum != null && (
|
||||
<span className="text-primary">Chapter {chapterNum}: </span>
|
||||
)}
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar */}
|
||||
<TtpSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<TtpNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<TtpNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TtpSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: TtpSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
{introItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Front Matter
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 mb-4">
|
||||
{introItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
isActive,
|
||||
}: {
|
||||
item: TtpSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/books/ttp/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TtpNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: TtpSidebarItem | null;
|
||||
next: TtpSidebarItem | null;
|
||||
sidebarItems: TtpSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
const introItems = sidebarItems.filter((i) => i.type === "intro-segment");
|
||||
if (introItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Front Matter",
|
||||
items: introItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ttp/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const chapterItems = sidebarItems.filter((i) => i.type === "chapter");
|
||||
if (chapterItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: chapterItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/ttp/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Transcendental Teachings of Prahlāda Mahārāja"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/ttp/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.type === "chapter" && prev.chapter != null
|
||||
? `Ch. ${prev.chapter}`
|
||||
: prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/ttp/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.type === "chapter" && next.chapter != null
|
||||
? `Ch. ${next.chapter}`
|
||||
: next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTtpMeta } from "@/lib/ttp";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("ttp");
|
||||
}
|
||||
|
||||
export default async function TtpPage() {
|
||||
const meta = await getTtpMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Description */}
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/ttp/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
Chapter {ch.number}
|
||||
</span>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getVaContent,
|
||||
getVaSidebarItems,
|
||||
type VaChapter,
|
||||
type VaSidebarItem,
|
||||
} from "@/lib/va";
|
||||
import { getProseChapter } from "@/lib/api";
|
||||
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||
import { BOOK_NAMES } from "@/lib/constants";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { chapter: slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const chapter = await getProseChapter("va", 0, slug);
|
||||
const hlQuote = extractQuoteFromHl(sp, chapter?.paragraphs);
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({ title: BOOK_NAMES["va"], quote: hlQuote });
|
||||
}
|
||||
return ogForChapter("va", chapter?.title || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function VaChapterPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chapter: string }>;
|
||||
}) {
|
||||
const { chapter: slug } = await params;
|
||||
|
||||
const content = await getVaContent(slug);
|
||||
if (!content) notFound();
|
||||
|
||||
const sidebarItems = await getVaSidebarItems();
|
||||
const currentIdx = sidebarItems.findIndex((item) => item.slug === slug);
|
||||
|
||||
const prev = currentIdx > 0 ? sidebarItems[currentIdx - 1] : null;
|
||||
const next =
|
||||
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
|
||||
|
||||
const chapterNum = (content as VaChapter).chapter;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href="/books/va"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Vaiśiṣṭyāṣṭakam
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
{content.title}
|
||||
</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
<VaSidebar items={sidebarItems} currentSlug={slug} />
|
||||
|
||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
<VaNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-4">
|
||||
{content.paragraphs.map((paragraph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VaNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sidebarItems={sidebarItems}
|
||||
currentSlug={slug}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VaSidebar({
|
||||
items,
|
||||
currentSlug,
|
||||
}: {
|
||||
items: VaSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.slug}
|
||||
href={`/books/va/${item.slug}`}
|
||||
className={cn(
|
||||
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
|
||||
item.slug === currentSlug
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function VaNav({
|
||||
prev,
|
||||
next,
|
||||
sidebarItems,
|
||||
currentSlug,
|
||||
}: {
|
||||
prev: VaSidebarItem | null;
|
||||
next: VaSidebarItem | null;
|
||||
sidebarItems: VaSidebarItem[];
|
||||
currentSlug: string;
|
||||
}) {
|
||||
const mobileNavGroups: MobileNavGroup[] = [];
|
||||
|
||||
if (sidebarItems.length > 0) {
|
||||
mobileNavGroups.push({
|
||||
title: "Chapters",
|
||||
items: sidebarItems.map((i) => ({
|
||||
slug: i.slug,
|
||||
label: i.label,
|
||||
href: `/books/va/${i.slug}`,
|
||||
type: i.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav
|
||||
groups={mobileNavGroups}
|
||||
currentSlug={currentSlug}
|
||||
bookTitle="Vaiśiṣṭyāṣṭakam"
|
||||
/>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/books/va/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/books/va/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getVaMeta } from "@/lib/va";
|
||||
import { ogForBook } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForBook("va");
|
||||
}
|
||||
|
||||
export default async function VaPage() {
|
||||
const meta = await getVaMeta();
|
||||
if (!meta) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/books"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Books
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
{meta.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">{meta.author}</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{meta.description && (
|
||||
<p className="text-muted-foreground">{meta.description}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Chapters
|
||||
</h2>
|
||||
<Card className="divide-y divide-border">
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{meta.chapters.map((ch) => (
|
||||
<li key={ch.number}>
|
||||
<Link
|
||||
href={`/books/va/${ch.number}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{ch.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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's working, what's broken, or what you'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'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'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
Reference in New Issue
Block a user