Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c72ce814d | ||
|
|
32da38b24f | ||
|
|
5951eb5e18 | ||
|
|
ab1517f32f | ||
|
|
326ba090ca | ||
|
|
9eb2a06b50 | ||
|
|
52924096d1 | ||
|
|
037072618b | ||
|
|
5cb5827f1c | ||
|
|
d7cd1cb4bd | ||
|
|
6e9fa4ee25 | ||
|
|
cbd9c55777 | ||
|
|
e5fb370997 | ||
|
|
0498e3c0ac | ||
|
|
c8f6a96169 | ||
|
|
a9b5417120 | ||
|
|
e19a4d912d | ||
|
|
9730ab0170 | ||
|
|
e6529ef62e | ||
|
|
99eb17da4b | ||
|
|
9fba172b31 | ||
|
|
7361f9c14a | ||
|
|
de374eab8b | ||
|
|
b81b18632d | ||
|
|
0a82a12580 | ||
|
|
237840148f | ||
|
|
ec5093e5cb | ||
|
|
391c57070a | ||
|
|
1bd046d6f3 | ||
|
|
1930f5e034 | ||
|
|
eb2cba3af1 | ||
|
|
49a60eb646 | ||
|
|
ea1914b283 | ||
|
|
545a7e2020 | ||
|
|
89f42b6eb3 | ||
|
|
45c080c571 | ||
|
|
004ff6ca2f | ||
|
|
e55ab47b66 | ||
|
|
7825293390 | ||
|
|
401766928a | ||
|
|
35d1d14f37 | ||
|
|
89106efc98 | ||
|
|
47a2dc375e | ||
|
|
ae6a752787 | ||
|
|
1f2ac2198a | ||
|
|
67b9ab6b16 | ||
|
|
5a2e556487 | ||
|
|
c865b59088 | ||
|
|
5235f479b3 | ||
|
|
c573c94c51 | ||
|
|
9b5823146f | ||
|
|
0aab1d23ec | ||
|
|
d88ababd76 | ||
|
|
abb7cc49d0 | ||
|
|
c0a00d4c04 | ||
|
|
213eea8e94 |
@@ -42,3 +42,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
public/rtfs
|
public/rtfs
|
||||||
/lib/generated/prisma
|
/lib/generated/prisma
|
||||||
|
/lib/jsons
|
||||||
|
.venv
|
||||||
|
scripts
|
||||||
@@ -0,0 +1,965 @@
|
|||||||
|
# Prabhupāda Says — Project Context
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
A Next.js app presenting the books, letters, essays, lectures, and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda in a modern, accessible, and searchable format with proper Unicode Sanskrit (IAST + Devanagari).
|
||||||
|
|
||||||
|
Content is sourced from the pre-1980s Vedabase, extracted and indexed into MongoDB, and served via a Next.js frontend. Extraction scripts and raw data live separately in the `extractor/` directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Next.js 16** with Turbopack, React 19, TypeScript
|
||||||
|
- **Tailwind CSS v4** with `@tailwindcss/postcss`
|
||||||
|
- **shadcn/ui** (base-nova style, lucide icons) — components: Button, Card, Badge, Tooltip, Input, Drawer
|
||||||
|
- **next-themes** for dark/light/system mode
|
||||||
|
- **vaul** for mobile drawer navigation
|
||||||
|
- **Prisma v6** with MongoDB (provider `prisma-client-js`)
|
||||||
|
- **MongoDB** native driver (`mongodb` package) for seeding (Prisma requires replica set for writes)
|
||||||
|
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
|
||||||
|
- **Database**: MongoDB at `DATABASE_URL` env variable with `?authSource=admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
The project is split into two directories:
|
||||||
|
|
||||||
|
- **`knowledgebase/`** — Production Next.js app (~3.5MB excluding node_modules)
|
||||||
|
- **`extractor/`** — Extraction scripts, seed scripts, raw data, legacy fonts (~750MB)
|
||||||
|
|
||||||
|
```
|
||||||
|
knowledgebase/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx → Landing page (Books, Essays, Magazines, Letters, Transcriptions, etc.)
|
||||||
|
│ ├── layout.tsx → ThemeProvider + TooltipProvider + TextSelectionToolbar + TextHighlighter
|
||||||
|
│ ├── globals.css → Vintage color palette + reading settings CSS
|
||||||
|
│ ├── about/page.tsx → About page (mission, data source, roadmap)
|
||||||
|
│ ├── books/ → 37 book routes (verse-based + prose)
|
||||||
|
│ │ ├── page.tsx → Books index (searchable grid, diacritic-aware search)
|
||||||
|
│ │ ├── chapter-viewer.tsx → Shared client component for verse-based books
|
||||||
|
│ │ ├── bg/, sb/, cc/, sb1962/, iso/, noi/, bs/, mm/, nbs/ → Verse-based books
|
||||||
|
│ │ ├── kb/, nod/, tlc/, ej/, ek/, mg/, bbd/, owk/, rv/ → Prose books
|
||||||
|
│ │ ├── poy/, pqpa/, ttp/, ochkm/, hkh/, lcl/, lob/ → More prose books
|
||||||
|
│ │ ├── ssr/, ds/ → Hierarchical prose books (sub-chapters)
|
||||||
|
│ │ └── sva/, gg/, mog/, cbhc/, bk/, pk/, va/ → Other books
|
||||||
|
│ ├── magazines/ → Magazine browsing (BTG 1944-1960, BTG 1966-1973)
|
||||||
|
│ ├── letters/
|
||||||
|
│ │ ├── page.tsx → Letters index with 3-way nav (year/recipient/location)
|
||||||
|
│ │ ├── filter-year-browser.tsx → Shared component: sidebar years + month-grouped content
|
||||||
|
│ │ ├── [slug]/page.tsx → Individual letter reader
|
||||||
|
│ │ ├── year/[year]/
|
||||||
|
│ │ │ ├── page.tsx → Letters by year
|
||||||
|
│ │ │ └── year-month-browser.tsx → Sidebar months + letter list
|
||||||
|
│ │ ├── recipient/[name]/page.tsx → Letters by recipient (year sidebar + month groups)
|
||||||
|
│ │ └── location/[name]/page.tsx → Letters by location (year sidebar + month groups)
|
||||||
|
│ ├── essays/ → 23 essays across 6 groups
|
||||||
|
│ ├── journals/ → Jaladuta Diary, The Beginning
|
||||||
|
│ ├── other-documents/ → 12 miscellaneous documents
|
||||||
|
│ ├── glossary/
|
||||||
|
│ │ ├── page.tsx → Redirects to /glossary/a
|
||||||
|
│ │ ├── glossary-client.tsx → Client component (sidebar terms, single entry view, prev/next nav)
|
||||||
|
│ │ └── [letter]/page.tsx → Dynamic route per letter (A-Z)
|
||||||
|
│ ├── disciples/ → 4,747 initiated disciples directory
|
||||||
|
│ │ ├── page.tsx → Disciples browser (letter nav, suffix filter, search, sort, pagination)
|
||||||
|
│ │ └── disciples-browser.tsx → Client component
|
||||||
|
│ ├── transcriptions/ → 3,787 transcriptions (lectures, conversations, philosophy, dictations)
|
||||||
|
│ │ ├── page.tsx → Browse by year / place / type (3 views)
|
||||||
|
│ │ ├── year/[year]/page.tsx → Year listing, grouped by month
|
||||||
|
│ │ ├── place/[place]/page.tsx → Place listing
|
||||||
|
│ │ ├── type/[type]/page.tsx → Type listing (lecture/conversation), year sidebar
|
||||||
|
│ │ └── [slug]/page.tsx → Individual transcription reading page
|
||||||
|
│ └── api/
|
||||||
|
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
|
||||||
|
│ ├── books/ → Book/verse API routes
|
||||||
|
│ └── glossary/route.ts → Glossary API
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer)
|
||||||
|
│ ├── selection-toolbar/ → Text selection toolbar sub-components
|
||||||
|
│ │ ├── shared.tsx → KautilyaIcon, types, hooks (useMediaQuery, useCopyFeedback)
|
||||||
|
│ │ ├── floating-toolbar.tsx → Desktop floating pill / mobile bottom bar
|
||||||
|
│ │ ├── share-modal.tsx → Share modal (desktop) / drawer (mobile)
|
||||||
|
│ │ ├── collections-drawer.tsx → Save to collection drawer
|
||||||
|
│ │ └── more-popover.tsx → More options popover (desktop) / drawer (mobile)
|
||||||
|
│ ├── text-selection-toolbar.tsx → Main orchestrator (event listeners, state management)
|
||||||
|
│ ├── text-highlighter.tsx → Processes incoming ?hl= URLs (scroll + highlight)
|
||||||
|
│ ├── page-header.tsx → Shared header with ReadingSettings (max-w-5xl)
|
||||||
|
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
|
||||||
|
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
|
||||||
|
│ ├── site-footer.tsx → Global footer (About + Home links)
|
||||||
|
│ ├── audio-player/ → Site-wide floating audio player
|
||||||
|
│ │ ├── audio-context.tsx → React context + provider, manages <audio> element
|
||||||
|
│ │ ├── floating-player.tsx → Floating player UI (desktop bar / mobile pill)
|
||||||
|
│ │ ├── audio-wave.tsx → Reusable animated audio wave bars
|
||||||
|
│ │ └── index.ts → Barrel exports
|
||||||
|
│ ├── logo.tsx → Theme-aware logo component
|
||||||
|
│ └── theme-provider.tsx → next-themes wrapper
|
||||||
|
├── lib/
|
||||||
|
│ ├── db.ts → Prisma client singleton
|
||||||
|
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary)
|
||||||
|
│ ├── constants.ts → Shared constants (BOOK_NAMES, CC_LILAS)
|
||||||
|
│ ├── og.ts → OG image metadata builders (ogForVerse, ogForLetter, etc.)
|
||||||
|
│ ├── text-highlight.ts → Text fragment encode/decode, highlight matching
|
||||||
|
│ ├── source-reference.ts → Source reference from URL (for copy attribution)
|
||||||
|
│ ├── disciples.ts → Disciples data access (by letter, place, year, search)
|
||||||
|
│ ├── letters.ts → Letters data access (by year, recipient, location)
|
||||||
|
│ ├── transcriptions.ts → Transcriptions data access (reads from Prisma/MongoDB)
|
||||||
|
│ ├── utils.ts → cn() helper
|
||||||
|
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.)
|
||||||
|
│ ├── other-documents.ts → Other Documents data access
|
||||||
|
│ ├── journals.ts → Journals data access
|
||||||
|
│ └── magazines.ts → Magazines data access
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma → All models (Verse, BookMeta, ProseChapter, Essay, Letter,
|
||||||
|
│ GlossaryEntry, Disciple, Transcription, etc.)
|
||||||
|
└── public/
|
||||||
|
├── fonts/Noto_Sans/static/ → NotoSans-Regular.ttf + NotoSans-Bold.ttf (OG images)
|
||||||
|
├── images/ → OG card image (Prabhupāda)
|
||||||
|
├── logo_dark.png → Logo for light theme
|
||||||
|
├── logo_light.png → Logo for dark theme
|
||||||
|
└── robots.txt
|
||||||
|
|
||||||
|
extractor/ → Extraction & seeding tools (not deployed)
|
||||||
|
├── scripts/ → Python extractors + TypeScript seed scripts
|
||||||
|
├── jsons/ → Extracted JSON data (books, letters, essays, etc.)
|
||||||
|
├── html/ → RTF→HTML converted files
|
||||||
|
├── rtfs/ → Original RTF source files
|
||||||
|
├── fonts/ → Legacy custom fonts (ScaGoudy, Indevr, Balaram, etc.)
|
||||||
|
├── app-rtfs/ → RTF viewer pages (archived)
|
||||||
|
└── .venv/ → Python virtual environment
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema (Prisma + MongoDB)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
type Synonym {
|
||||||
|
word String
|
||||||
|
meanings String[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Verse {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String // "bg" | "sb" | "cc" | "sb1962" | "iso" | "noi"
|
||||||
|
division String? // null for BG/SB1962/ISO/NOI; canto for SB; lila for CC
|
||||||
|
chapter Int
|
||||||
|
verse Int
|
||||||
|
verseEnd Int?
|
||||||
|
reference String
|
||||||
|
bookTitle String
|
||||||
|
devanagari String
|
||||||
|
bengali String? // only CC has Bengali
|
||||||
|
transliteration String
|
||||||
|
synonyms Synonym[]
|
||||||
|
translation String
|
||||||
|
purport String[]
|
||||||
|
|
||||||
|
@@unique([book, division, chapter, verse])
|
||||||
|
@@index([book])
|
||||||
|
@@index([book, division])
|
||||||
|
@@index([book, division, chapter])
|
||||||
|
@@map("verses")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BookMeta {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String @unique
|
||||||
|
data Json
|
||||||
|
|
||||||
|
@@map("book_meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProseChapter {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String
|
||||||
|
volume Int // 0 = no volumes (sentinel for non-volume books)
|
||||||
|
chapter Int? // null for intro segments
|
||||||
|
title String
|
||||||
|
type String // "chapter" | "intro-segment" | "conclusion"
|
||||||
|
slug String
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
|
||||||
|
@@unique([book, volume, slug])
|
||||||
|
@@index([book])
|
||||||
|
@@index([book, volume])
|
||||||
|
@@index([book, volume, type])
|
||||||
|
@@map("prose_chapters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Essay {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
author String
|
||||||
|
year Int?
|
||||||
|
group String? // e.g., "two-essays"
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
|
||||||
|
@@index([group])
|
||||||
|
@@map("essays")
|
||||||
|
}
|
||||||
|
|
||||||
|
model OtherDocumentMeta {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String @unique
|
||||||
|
data Json
|
||||||
|
|
||||||
|
@@map("other_document_meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
model OtherDocumentChapter {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String
|
||||||
|
chapter Int
|
||||||
|
title String
|
||||||
|
type String
|
||||||
|
slug String
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
|
||||||
|
@@unique([book, slug])
|
||||||
|
@@index([book])
|
||||||
|
@@map("other_document_chapters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model JournalMeta {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String @unique
|
||||||
|
data Json
|
||||||
|
|
||||||
|
@@map("journal_meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
model JournalChapter {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String
|
||||||
|
chapter Int
|
||||||
|
title String
|
||||||
|
type String
|
||||||
|
slug String
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
|
||||||
|
@@unique([book, slug])
|
||||||
|
@@index([book])
|
||||||
|
@@map("journal_chapters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MagazineMeta {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String @unique
|
||||||
|
data Json
|
||||||
|
|
||||||
|
@@map("magazine_meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MagazineIssue {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
book String
|
||||||
|
volume Int
|
||||||
|
part Int
|
||||||
|
title String
|
||||||
|
slug String
|
||||||
|
order Int
|
||||||
|
paragraphs String[]
|
||||||
|
paragraphsJson String? // JSON string fallback for large issues (BSON wire protocol workaround)
|
||||||
|
|
||||||
|
@@unique([book, slug])
|
||||||
|
@@index([book])
|
||||||
|
@@index([book, volume])
|
||||||
|
@@map("magazine_issues")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Letter {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
slug String @unique
|
||||||
|
recipients String[] // array of individual recipient names
|
||||||
|
recipientDisplay String // original combined string for display
|
||||||
|
date String?
|
||||||
|
dateRef String?
|
||||||
|
year Int?
|
||||||
|
month Int?
|
||||||
|
day Int?
|
||||||
|
location String?
|
||||||
|
salutation String?
|
||||||
|
paragraphs String[]
|
||||||
|
|
||||||
|
@@index([year])
|
||||||
|
@@index([recipients])
|
||||||
|
@@index([location])
|
||||||
|
@@index([year, month])
|
||||||
|
@@map("letters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GlossaryEntry {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
term String
|
||||||
|
definition String
|
||||||
|
definitionHtml String
|
||||||
|
|
||||||
|
@@unique([term])
|
||||||
|
@@map("glossary")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Disciple {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
initiatedName String
|
||||||
|
suffix String // "Dasa" | "Devi Dasi" | "Sw"
|
||||||
|
initiationDate DateTime? // 1st of the month (exact day unknown unless verified)
|
||||||
|
initiationPlace String?
|
||||||
|
karmiName String? // pre-initiation (birth) name
|
||||||
|
avatar String? // URL to profile photo
|
||||||
|
about String? // biographical notes
|
||||||
|
tags String[]
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
|
||||||
|
@@index([initiatedName])
|
||||||
|
@@index([initiationPlace])
|
||||||
|
@@index([initiationDate])
|
||||||
|
@@index([suffix])
|
||||||
|
@@index([tags])
|
||||||
|
@@map("disciples")
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentBlock {
|
||||||
|
text String
|
||||||
|
format String @default("paragraph")
|
||||||
|
alignment String @default("left")
|
||||||
|
speaker String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Transcription {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
type String // "lecture" | "conversation"
|
||||||
|
date String?
|
||||||
|
year Int?
|
||||||
|
month Int?
|
||||||
|
day Int?
|
||||||
|
place String?
|
||||||
|
context String?
|
||||||
|
reference String?
|
||||||
|
summary String?
|
||||||
|
content ContentBlock[]
|
||||||
|
tags String[]
|
||||||
|
participants String[]
|
||||||
|
language String @default("en")
|
||||||
|
source String?
|
||||||
|
durationMinutes Int?
|
||||||
|
audioUrl String?
|
||||||
|
isPublished Boolean @default(false)
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
@@map("transcriptions")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disciples Collection
|
||||||
|
|
||||||
|
Scraped from `https://sp.krishna.com/disciples_a` through `disciples_y` (21 letter pages; F, Q, W, X, Z have no pages).
|
||||||
|
|
||||||
|
- **Total**: 4,747 disciples
|
||||||
|
- **3,296** Dasa, **1,450** Devi Dasi, **1** Svāmī
|
||||||
|
- **4,554** have initiation dates (month + year); **193** are missing dates
|
||||||
|
- **4,703** have initiation places; **44** are missing places
|
||||||
|
- Initiation dates are set to the **1st day of the month** (exact day unknown from this source)
|
||||||
|
- On the UI, exact dates should only be shown when `isVerified` is `true`
|
||||||
|
- `karmiName`, `avatar`, `about`, and `tags` are empty by default — intended for manual curation
|
||||||
|
|
||||||
|
Data files: `data/disciples_a_b_c_d_e.json`, `data/disciples_g_to_l.json`, `data/disciples_m_n_o_p.json`, `data/disciples_r_s_t.json`, `data/disciples_u_v_y.json`
|
||||||
|
|
||||||
|
Seed script: `npx tsx scripts/seed-disciples.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Books Extracted
|
||||||
|
|
||||||
|
### Verse-based Books (stored in `verses` collection)
|
||||||
|
|
||||||
|
| Book | Code | Verses | Chapters | Division |
|
||||||
|
|------|------|--------|----------|----------|
|
||||||
|
| Bhagavad-gītā As It Is | `bg` | 653 | 18 | — |
|
||||||
|
| Śrīmad-Bhāgavatam | `sb` | 8,258 | 213 | 10 cantos |
|
||||||
|
| Śrī Caitanya-caritāmṛta | `cc` | 11,348 | 62 | 3 līlās |
|
||||||
|
| SB First Canto (1962 Edition) | `sb1962` | 762 | 19 | — |
|
||||||
|
| Śrī Īśopaniṣad | `iso` | 19 | — | Invocation + 18 mantras |
|
||||||
|
| The Nectar of Instruction | `noi` | 11 | — | 11 texts |
|
||||||
|
| Śrī Brahma-saṁhitā | `bs` | 62 | 1 (Ch. 5) | — |
|
||||||
|
| Mukunda-mālā-stotra | `mm` | 6 | 1 | — |
|
||||||
|
| Nārada-bhakti-sūtra | `nbs` | 8 | 1 | — |
|
||||||
|
|
||||||
|
### Prose Books (stored in `prose_chapters` collection)
|
||||||
|
|
||||||
|
| Book | Code | Chapters | Source |
|
||||||
|
|------|------|----------|--------|
|
||||||
|
| Kṛṣṇa Book | `kb` | 89 (2 volumes) | other.html |
|
||||||
|
| The Nectar of Devotion | `nod` | 51 (4 parts) | other.html |
|
||||||
|
| Teachings of Lord Caitanya | `tlc` | 32 (2 parts) | other.html (1968 ed., plain English) |
|
||||||
|
| Easy Journey to Other Planets | `ej` | 2 | other.html |
|
||||||
|
| Elevation to Kṛṣṇa Consciousness | `ek` | 6 | other.html |
|
||||||
|
| Kṛṣṇa Consciousness: The Matchless Gift | `mg` | 7 | other.html |
|
||||||
|
| Beyond Birth and Death | `bbd` | 5 | other.html |
|
||||||
|
| On the Way to Kṛṣṇa | `owk` | 5 | other.html |
|
||||||
|
| Rāja-vidyā: The King of Knowledge | `rv` | 8 | other.html |
|
||||||
|
| The Perfection of Yoga | `poy` | 11 | other.html |
|
||||||
|
| Perfect Questions Perfect Answers | `pqpa` | 9 | other.html |
|
||||||
|
| Transcendental Teachings of Prahlāda Mahārāja | `ttp` | 5 | other.html |
|
||||||
|
| On Chanting the Hare Kṛṣṇa Mantra | `ochkm` | 4 | other.html |
|
||||||
|
| The Kṛṣṇa Consciousness Handbook | `hkh` | 19 | other.html |
|
||||||
|
| The Science of Self-Realization | `ssr` | 50 sub-chapters (8 parents) | other.html |
|
||||||
|
| Dialectic Spiritualism | `ds` | 36 sub-chapters (13 parents) | other.html |
|
||||||
|
| Life Comes from Life | `lcl` | 16 | other.html |
|
||||||
|
| Light of the Bhāgavata | `lob` | 48 | other.html |
|
||||||
|
| Teachings of Lord Kapila | `tlk` | 18 | other.html |
|
||||||
|
| Teachings of Queen Kuntī | `tqk` | 26 | other.html |
|
||||||
|
| Gītār Gān | `gg` | 18 | other.html (Bengali poetic BG) |
|
||||||
|
| Songs of the Vaiṣṇava Ācāryas | `sva` | 67 songs (4 parts, hierarchical) | other.html |
|
||||||
|
| Message of Godhead | `mog` | 3 | others-before-1977.html |
|
||||||
|
| Śrī Caitanya-bhāgavata Commentary | `cbhc` | 4 | others-before-1977.html |
|
||||||
|
| Bhagavāner Kathā | `bk` | 2 | others-before-1977.html (ScaGoudy) |
|
||||||
|
| Prayers of King Kulaśekhara (1958) | `pk` | 6 | others-before-1977.html |
|
||||||
|
| Vaiśiṣṭyāṣṭakam | `va` | 8 | others-before-1977.html |
|
||||||
|
|
||||||
|
### Essays (stored in `essays` collection)
|
||||||
|
|
||||||
|
| Essay | Slug | Group | Year |
|
||||||
|
|-------|------|-------|------|
|
||||||
|
| Krishna The Reservoir Of Pleasure | `krishna-reservoir-of-pleasure` | `two-essays` | 1966 |
|
||||||
|
| Who Is Crazy? | `who-is-crazy` | `two-essays` | 1966 |
|
||||||
|
| The Spiritual Masters | `the-spiritual-masters` | `cc-essays` | 1960 |
|
||||||
|
| The Absolute Truth | `the-absolute-truth` | `cc-essays` | 1957 |
|
||||||
|
| The Causes of Lord Caitanya's Incarnation | `causes-of-lord-caitanyas-incarnation` | `cc-essays` | 1957 |
|
||||||
|
| The Confidential Reasons for Lord Caitanya's Appearance | `confidential-reasons-for-lord-caitanyas-appearance` | `cc-essays` | 1957 |
|
||||||
|
| The Glories of Lord Nityānanda | `glories-of-lord-nityananda` | `cc-essays` | 1957 |
|
||||||
|
| Man Is the Architect of His Own Fortune | `man-is-the-architect-of-his-own-fortune` | `pre-1977-essays` | — |
|
||||||
|
| What Is the Matter with the World? | `what-is-the-matter-with-the-world` | `pre-1977-essays` | 1957 |
|
||||||
|
| Religion Is Godly Life | `religion-is-godly-life` | `pre-1977-essays` | — |
|
||||||
|
| Truth and Beauty | `truth-and-beauty` | `pre-1977-essays` | 1958 |
|
||||||
|
| Misuse of Public Fund in the Name of Religion | `misuse-of-public-fund-in-the-name-of-religion` | `pre-1977-essays` | 1958 |
|
||||||
|
| Perfection at Home | `perfection-at-home` | `pre-1977-essays` | — |
|
||||||
|
| How Should One Cultivate Human Spirit | `how-should-one-cultivate-human-spirit` | `pre-1977-essays` | 1961 |
|
||||||
|
| Viswa Dharma | `viswa-dharma` | `pre-1977-essays` | — |
|
||||||
|
| Glorification of Śrīla Bhaktisiddhānta Sarasvatī Ṭhākura | `glorification-of-srila-bhaktisiddhanta-sarasvati-thakura` | `pre-1977-essays` | 1952 |
|
||||||
|
| Who Is Crazy? (Back to Godhead #66) | `who-is-crazy-btg66` | `pre-1977-essays` | — |
|
||||||
|
| Manifestation of Dharma in Human Being | `manifestation-of-dharma-in-human-being` | `pre-1977-essays` | 1972 |
|
||||||
|
| Message of Krishna Consciousness | `message-of-krishna-consciousness` | `pre-1977-essays` | 1972 |
|
||||||
|
| A Wrong Civilization | `a-wrong-civilization` | `pre-1977-essays` | 1936 |
|
||||||
|
| Vedānta-sūtra (Typed Transcript) | `vedanta-sutra-typed` | `typed-transcripts` | 1969 |
|
||||||
|
| Shaktipujah | `shaktipujah` | `reviews` | — |
|
||||||
|
| Devotee and the Divinity | `devotee-and-the-divinity` | `standalone` | — |
|
||||||
|
|
||||||
|
### Other Documents (stored in `other_document_meta` + `other_document_chapters` collections)
|
||||||
|
|
||||||
|
| Document | Code | Chapters |
|
||||||
|
|----------|------|----------|
|
||||||
|
| An Analysis of Bhagwat Geeta | `analysis-bg` | 3 |
|
||||||
|
| Solution of Present Crisis by Bhagwat Geeta | `solution-crisis` | 4 |
|
||||||
|
| Śrī Caitanya Mahāprabhu | `sri-caitanya` | 3 |
|
||||||
|
| Conception of Geeta Nagari on the Principles of Gandhi | `geeta-nagari` | 1 |
|
||||||
|
| The Real Peace Formula | `real-peace-formula` | 3 |
|
||||||
|
| International Institution for God Consciousness | `intl-institution-god-consciousness` | 1 |
|
||||||
|
| "Srimad Bhawatam" India's Message of Peace and Goodwill | `srimad-bhawatam-message` | 1 |
|
||||||
|
| Viraha Aṣṭaka | `viraha-astaka` | 8 |
|
||||||
|
| Vṛndāvana Bhajana | `vrndavana-bhajana` | 1 |
|
||||||
|
| Prayer unto the Lotus Feet of Kṛṣṇa | `prayer-unto-lotus-feet-of-krsna` | 1 |
|
||||||
|
| Märkine Bhägavata-dharma | `markine-bhagavata-dharma` | 1 |
|
||||||
|
| Jaladuta Pamphlet | `jaladuta-pamphlet` | 1 |
|
||||||
|
|
||||||
|
### Journals (stored in `journal_meta` + `journal_chapters` collections)
|
||||||
|
|
||||||
|
| Journal | Code | Chapters | Structure |
|
||||||
|
|---------|------|----------|-----------|
|
||||||
|
| The Jaladuta Diary, 1965 | `jaladuta-diary` | 85 | Preface, Introduction, 83 day entries grouped by month (Apr–Nov 1965) |
|
||||||
|
| The Beginning — The 1966 New York Journal | `the-beginning` | 215 | Introduction, 3 place-based chapters with day entries, Memoranda, Epilogue, 20 Excerpts |
|
||||||
|
|
||||||
|
### Magazines (stored in `magazine_meta` + `magazine_issues` collections)
|
||||||
|
|
||||||
|
| Magazine | Code | Issues | Articles | Flag | Source |
|
||||||
|
|----------|------|--------|----------|------|--------|
|
||||||
|
| Back to Godhead (1944–1960) | `btg` | 29 | 89 | Pre 1978 | btg-1960.html |
|
||||||
|
| Back to Godhead (1966–1973) | `btg-1978` | 30 | 1,035 | Pre 1978 | btg-1978.html |
|
||||||
|
|
||||||
|
### Letters (stored in `letters` collection)
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Total letters | 6,866 |
|
||||||
|
| Unique recipients (individual) | 2,243 |
|
||||||
|
| Unique locations | 164 |
|
||||||
|
| Year range | 1947–1977 |
|
||||||
|
| Total paragraphs | 52,261 |
|
||||||
|
|
||||||
|
**Top recipients**: Satsvarupa (182), Brahmananda (168), Hansadutta (117), Tamala Krsna (115), Gurudasa (103), Mukunda (105), Madhudvisa (90), Karandhara (90)
|
||||||
|
|
||||||
|
**Top locations**: Los Angeles (1,392), Bombay (1,086), Vrindaban (532), Mayapur (305), London (264), Calcutta (250), Honolulu (239)
|
||||||
|
|
||||||
|
**Schema notes**:
|
||||||
|
- `recipients` is an array of individual names (e.g., `["Mukunda", "Janaki"]`)
|
||||||
|
- `recipientDisplay` is the original combined string (e.g., `"Mukunda, Janaki"`)
|
||||||
|
- Searching for a recipient uses Prisma `{ has: name }` to match any letter where that person appears
|
||||||
|
- Multi-recipient groups are split by `,`, `;`, ` and `, ` & `
|
||||||
|
- Trailing punctuation (`:`, `,`, `.`) cleaned from names
|
||||||
|
|
||||||
|
### Transcriptions (stored in `transcriptions` collection)
|
||||||
|
|
||||||
|
3,787 transcriptions of Śrīla Prabhupāda's lectures and conversations (1966–1977), plus 59 philosophy discussions and 12 SB dictations.
|
||||||
|
|
||||||
|
- **Lectures** (2,179): Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, Nectar of Devotion, Īśopaniṣad, Brahma-saṁhitā, and general lectures
|
||||||
|
- **Conversations** (1,537): Morning walks, room conversations, interviews, press conferences, initiations
|
||||||
|
- **Philosophy Discussions** (59): Western philosophers discussed with Śyāmasundara and Hayagrīva
|
||||||
|
- **SB Dictations** (12): Śrīmad-Bhāgavatam 3rd Canto translation/purport dictations
|
||||||
|
- **Audio**: 99.1% have linked audio recordings from Cloudflare R2 (`audioUrl` field)
|
||||||
|
- **Empty content**: 30 transcriptions (Hindi/Bengali lectures) have no English text but may have audio
|
||||||
|
|
||||||
|
Data layer: `lib/transcriptions.ts` (reads from Prisma/MongoDB, NOT local JSON files)
|
||||||
|
|
||||||
|
Seed script: `cd knowledgebase && npx tsx scripts/seed-transcriptions.ts`
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
| Content | Collection |
|
||||||
|
|---------|-----------|
|
||||||
|
| Glossary (2,749 entries) | `glossary` |
|
||||||
|
| Transcriptions (3,787 entries) | `transcriptions` collection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Font Encoding System
|
||||||
|
|
||||||
|
### ScaGoudy → IAST mapping (used in BG, SB, CC, ISO, NOI, NoD, and most 1969+ books)
|
||||||
|
|
||||||
|
```
|
||||||
|
ä→ā Ä→Ā å→ṛ Å→Ṛ ñ→ṣ Ñ→Ṣ ö→ṭ Ö→Ṭ ë→ṇ Ë→Ṇ ò→ḍ Ò→Ḍ
|
||||||
|
ç→ś Ç→Ś ì→ṅ ï→ñ ü→ū Ü→Ū é→ī É→Ī à→ṁ ù→ḥ è→ṝ û→ṁl ÿ→ḷ
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bengali fonts (Inbenr/Inbeni in CC)
|
||||||
|
|
||||||
|
Custom-encoded fonts where ASCII codepoints render as Bengali script. Fixed by `fix-bengali-encoding.py`.
|
||||||
|
|
||||||
|
### RM Devanagari font (used in ISO, NOI)
|
||||||
|
|
||||||
|
Custom-encoded Devanagari font (p51 CSS class). The text appears as garbled ASCII without the font. **Skipped during extraction** — Unicode Devanagari is generated from IAST transliteration instead.
|
||||||
|
|
||||||
|
### Devanagari generation
|
||||||
|
|
||||||
|
IAST transliteration → Devanagari via `indic-transliteration` library. Key fixes:
|
||||||
|
- `join_virama_words()` — joins virāma + consonant AND virāma + vowel
|
||||||
|
- `normalize_devanagari_anusvara()` — ङ् → ं before velars, ञ् → ं before palatals
|
||||||
|
|
||||||
|
### Books without ScaGoudy encoding
|
||||||
|
|
||||||
|
- **TLC (1968 Edition)** — plain English transliterations ("Krishna" not "Kṛṣṇa")
|
||||||
|
- **Two Essays (1966)** — plain English
|
||||||
|
- **GG (Gītār Gān)** — Bengali poetic text (ScaGoudy decoded for transliteration)
|
||||||
|
- **LCL (Life Comes from Life)** — dialogue book, ScaGoudy decoded
|
||||||
|
- **MOG, CBHC, PK** — pre-1977 handwritten documents, plain English
|
||||||
|
- **BK (Bhagavāner Kathā)** — pre-1977, uses ScaGoudy encoding
|
||||||
|
- **CC Essays, Vedānta-sūtra transcript** — plain English
|
||||||
|
- All other books from other.html use ScaGoudy encoding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Book Types and URL Patterns
|
||||||
|
|
||||||
|
### Verse-based books (use ChapterViewer client component)
|
||||||
|
|
||||||
|
| Route Pattern | Books |
|
||||||
|
|---------------|-------|
|
||||||
|
| `/books/bg/[chapter]/[verse]` | BG |
|
||||||
|
| `/books/sb/[canto]/[chapter]/[verse]` | SB |
|
||||||
|
| `/books/cc/[lila]/[chapter]/[verse]` | CC |
|
||||||
|
| `/books/sb1962/[chapter]/[verse]` | SB1962 |
|
||||||
|
| `/books/iso/[mantra]` | Īśopaniṣad (verse + intro segments) |
|
||||||
|
| `/books/noi/[text]` | Nectar of Instruction (verse + intro segments) |
|
||||||
|
| `/books/bs/[chapter]/[verse]` | Brahma-saṁhitā (chapter 5, 62 verses) |
|
||||||
|
| `/books/mm/[chapter]/[verse]` | Mukunda-mālā-stotra (6 mantras) |
|
||||||
|
| `/books/nbs/[chapter]/[verse]` | Nārada-bhakti-sūtra (8 sūtras) |
|
||||||
|
|
||||||
|
### Prose books (use per-book data layers + reading pages)
|
||||||
|
|
||||||
|
| Route Pattern | Books |
|
||||||
|
|---------------|-------|
|
||||||
|
| `/books/kb/[volume]/[slug]` | Kṛṣṇa Book |
|
||||||
|
| `/books/{code}/[chapter]` | NoD, TLC, EJ, EK, MG, BBD, OWK, RV, POY, PQPA, TTP, OCHKM, HKH, LCL, LOB, TLK, TQK, GG, MOG, CBHC, BK, PK |
|
||||||
|
|
||||||
|
### Hierarchical prose books (sub-chapters as separate pages)
|
||||||
|
|
||||||
|
| Route Pattern | Books |
|
||||||
|
|---------------|-------|
|
||||||
|
| `/books/ssr/[chapter]` | Science of Self-Realization (slugs: "1-1", "1-2", "intro-foreword", etc.) |
|
||||||
|
| `/books/ds/[chapter]` | Dialectic Spiritualism (slugs: "1-1" = Socrates, "1-2" = Plato, etc.) |
|
||||||
|
| `/books/sva/[chapter]` | Songs of the Vaiṣṇava Ācāryas (slugs: "1-1" = Śrī Guru Praṇāma, etc.) |
|
||||||
|
|
||||||
|
### Essays
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/essays` | Searchable essay grid (23 essays across 6 groups) |
|
||||||
|
| `/essays/[slug]` | Individual essay reader |
|
||||||
|
|
||||||
|
### Other Documents
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/other-documents` | Searchable other documents grid (12 documents) |
|
||||||
|
| `/other-documents/[slug]` | Document index (chapter listing) |
|
||||||
|
| `/other-documents/[slug]/[chapter]` | Individual chapter reader |
|
||||||
|
|
||||||
|
### Journals
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/journals` | Journals index (grid) |
|
||||||
|
| `/journals/[slug]` | Journal chapter listing (hierarchical: place → month → day) |
|
||||||
|
| `/journals/[slug]/[chapter]` | Individual journal entry reader (slug-based, e.g., `apr-3`, `sep-17`, `excerpt-...`) |
|
||||||
|
|
||||||
|
### Magazines
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/magazines` | Magazines index (grouped by flag: "Pre 1978 Editions") |
|
||||||
|
| `/magazines/[slug]` | Magazine issue listing (grouped by volume) |
|
||||||
|
| `/magazines/[slug]/[issue]` | Individual issue reader (slug-based, e.g., `v1-p1`, `no-17`) |
|
||||||
|
|
||||||
|
### Letters
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/letters` | Letters index with 3-way navigation tabs |
|
||||||
|
| `/letters?view=year` | Year grid (1947–1977) with search |
|
||||||
|
| `/letters?view=recipient` | Recipient list (2,243 individuals) with search + pagination |
|
||||||
|
| `/letters?view=location` | Location grid (164 locations) with search + pagination |
|
||||||
|
| `/letters/year/1968` | All letters from a specific year |
|
||||||
|
| `/letters/recipient/Mukunda` | All letters to/including a specific recipient |
|
||||||
|
| `/letters/location/Los%20Angeles` | All letters from a specific location |
|
||||||
|
| `/letters/[slug]` | Individual letter reader with prev/next navigation |
|
||||||
|
|
||||||
|
### Transcriptions
|
||||||
|
|
||||||
|
```
|
||||||
|
/transcriptions → Browse by year / place / type
|
||||||
|
/transcriptions?view=year → Year grid view
|
||||||
|
/transcriptions?view=place → Place grid view
|
||||||
|
/transcriptions?view=type → Lecture vs Conversation cards
|
||||||
|
/transcriptions/year/[year] → Year listing, grouped by month
|
||||||
|
/transcriptions/place/[place] → Place listing
|
||||||
|
/transcriptions/type/[type] → Type listing (lecture/conversation), year sidebar
|
||||||
|
/transcriptions/[slug] → Individual transcription reading page
|
||||||
|
```
|
||||||
|
|
||||||
|
Each transcription page shows:
|
||||||
|
- Title, type badge (Lecture/Conversation), date, place, reference, duration
|
||||||
|
- Participants list
|
||||||
|
- Play Audio button (links to Cloudflare R2 mp3)
|
||||||
|
- Content blocks: paragraphs with speaker labels, verses (centered italic), quotes (border-left), notes
|
||||||
|
- Prev/next navigation within same year
|
||||||
|
- `?hl=` text highlight sharing support with OG image
|
||||||
|
|
||||||
|
### Other routes
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/` | Landing page (responsive 3-column grid) |
|
||||||
|
| `/books` | Searchable book grid |
|
||||||
|
| `/glossary` | Searchable glossary (2,749 entries) |
|
||||||
|
| `/journals` | Journals (2 journals, 300 chapters) |
|
||||||
|
| `/magazines` | Magazines (2 magazines, 59 issues, Pre 1978) |
|
||||||
|
| `/letters` | Letters (6,866 letters, 3-way navigation) |
|
||||||
|
| `/rtfs` | RTF viewer (gated by ALLOW_RTF=true) |
|
||||||
|
| `/api/verify-access` | Password protection endpoint |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Volume Convention
|
||||||
|
|
||||||
|
Books without volumes use `volume: 0` as a sentinel value in the `prose_chapters` collection. This satisfies the Prisma schema's `@@unique([book, volume, slug])` constraint without requiring schema changes.
|
||||||
|
|
||||||
|
- KB uses `volume: 1` and `volume: 2`
|
||||||
|
- All other prose books use `volume: 0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hierarchical Books (SSR, DS)
|
||||||
|
|
||||||
|
SSR and DS have parent chapters with sub-chapters stored as individual `prose_chapters` docs:
|
||||||
|
|
||||||
|
- Sub-chapter slugs: `"1-1"`, `"1-2"`, `"2-1"`, etc.
|
||||||
|
- Parent chapter info stored in `intro.json` metadata:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chapters": [
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"title": "Learning the Science of the Soul",
|
||||||
|
"subChapters": [
|
||||||
|
{ "slug": "1-1", "title": "Seeing the Aim of Human Life" },
|
||||||
|
{ "slug": "1-2", "title": "Your Original Consciousness Is Kṛṣṇa Consciousness" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Sidebar UI shows parent headings (non-clickable) with sub-chapters indented below
|
||||||
|
- Prev/next navigation skips parent headings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Features
|
||||||
|
|
||||||
|
### Reading Settings (accessibility)
|
||||||
|
- **Theme**: Light / Dark / System toggle
|
||||||
|
- **Serif Font**: Toggle to switch reading content to Georgia/serif
|
||||||
|
- **Font Size**: Slider (XS → XXL) using CSS `zoom` on `main` and `aside`
|
||||||
|
- **Justify Text**: Toggle for justified text alignment on reading content
|
||||||
|
- Settings persist in `localStorage` under `spsays_reading_prefs`
|
||||||
|
- Applied via CSS classes on `<html>` element
|
||||||
|
|
||||||
|
### Mobile Navigation
|
||||||
|
- Left-slide drawer (vaul) triggered by a menu button next to the Previous button
|
||||||
|
- Only visible below `lg` breakpoint
|
||||||
|
- Shows the full sidebar navigation grouped by section
|
||||||
|
- Implemented in all 24+ reader pages via shared `MobileNav` component
|
||||||
|
|
||||||
|
### Search
|
||||||
|
- Books page: diacritic-aware search (typing "Krsna" matches "Kṛṣṇa")
|
||||||
|
- Glossary page: full-text search across terms and definitions
|
||||||
|
- Uses Unicode NFD normalization + manual IAST character stripping
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
- **Light mode ("Aged Parchment")**: Warm cream backgrounds, deep sepia text, saffron/ochre primary
|
||||||
|
- **Dark mode ("Ancient Manuscript")**: Deep warm brown backgrounds, parchment text, golden saffron primary
|
||||||
|
|
||||||
|
### Text Selection Toolbar
|
||||||
|
- Global floating toolbar appears on text selection across all reading pages (except RTFs)
|
||||||
|
- **Desktop**: floating pill above selection; **Mobile**: fixed bottom bar
|
||||||
|
- Actions: Ask Kautilya (Phase II), Share, Copy (with source reference), More
|
||||||
|
- **Share**: modal (desktop) / bottom drawer (mobile) — share to feed, copy highlight link
|
||||||
|
- **More**: popover (desktop) / bottom drawer (mobile) — bookmark, save to collection, copy link, mention
|
||||||
|
- **Copy** appends source reference: title, chapter/verse, highlight URL
|
||||||
|
- All toolbar surfaces use dark contrast treatment (bg-black, border-white/10)
|
||||||
|
- Kautilya lotus icon for the AI companion button
|
||||||
|
- On iOS Safari mobile, selection is saved/restored when drawers open/close to prevent focus conflicts
|
||||||
|
|
||||||
|
### Text Highlight Links
|
||||||
|
- Selected text encoded as compact URL fragment: `?hl=prefix§suffix§length`
|
||||||
|
- Uses `§` delimiter (safe for Sanskrit, English, and all Vedic text)
|
||||||
|
- Short selections (≤8 words) use full text as prefix
|
||||||
|
- Incoming `?hl=` URLs: text is found in DOM, wrapped in `<mark>`, scrolled to, highlighted permanently
|
||||||
|
- `TextHighlighter` component runs on page load via `useSearchParams`
|
||||||
|
- Server-side `extractMatchedText()` searches paragraphs for OG image quote extraction
|
||||||
|
- Case-insensitive matching for both DOM highlighting and server-side text extraction
|
||||||
|
- Cross-paragraph matching via position mapping (spaces inserted at element boundaries)
|
||||||
|
|
||||||
|
### Dynamic OG Images
|
||||||
|
- `/api/og` route generates 1200×630 PNG images using `next/og` (Satori)
|
||||||
|
- Left panel: Prabhupāda image with quote mark and branding (from PNG)
|
||||||
|
- Right panel: title (bold), detail, quote text, "Read more at prabhupadasays.org"
|
||||||
|
- Font: Noto Sans Regular + Bold loaded from `public/fonts/Noto_Sans/static/` — full IAST diacritical support
|
||||||
|
- All 104 content pages have `generateMetadata` with contextual fallback descriptions
|
||||||
|
- When `?hl=` is present, the actual selected text is extracted from DB content for the OG quote
|
||||||
|
|
||||||
|
### Audio Player
|
||||||
|
|
||||||
|
A site-wide floating audio player for transcription recordings (and future music library). Built with framer-motion for animations.
|
||||||
|
|
||||||
|
- **Desktop**: Centered floating bar (max-width 680px) with cover image, track info, transport controls (prev/play/next), volume slider, source link, close button
|
||||||
|
- **Mobile**: Auto-minimizes to a floating pill (cover image with circular progress ring and audio wave animation) after 8 seconds of inactivity. Tap to expand. Full player shows two rows: track info row + centered transport controls
|
||||||
|
- **Z-index**: `z-40` — below text selection toolbar (z-80), drawers (z-90), modals (z-100)
|
||||||
|
- **Hidden when selection toolbar is active** on mobile to avoid overlap
|
||||||
|
- **Forced dark surface** (`bg-[#1c1c1e]`) for accessibility contrast regardless of theme
|
||||||
|
- **Keyboard shortcuts**: Space (play/pause), ←/→ (seek ±10s), ↑/↓ (volume), M (mute), Esc (close)
|
||||||
|
- **Progress seeker**: 8px tall bar at top of player, click/drag to seek
|
||||||
|
- **Volume**: Persisted to localStorage. Mobile relies on hardware volume buttons
|
||||||
|
- **framer-motion**: Spring animations for enter/exit, AnimatePresence for minimize/expand transitions, motion.div for audio wave bars
|
||||||
|
|
||||||
|
Components:
|
||||||
|
- `components/audio-player/audio-context.tsx` — React context + provider, manages `<audio>` element
|
||||||
|
- `components/audio-player/floating-player.tsx` — Floating player UI
|
||||||
|
- `components/audio-player/audio-wave.tsx` — Reusable animated audio wave bars
|
||||||
|
- `components/audio-player/index.ts` — Barrel exports
|
||||||
|
|
||||||
|
The `AudioPlayerProvider` wraps the entire app in `layout.tsx`. The `FloatingPlayer` is rendered before `SiteFooter`.
|
||||||
|
|
||||||
|
### Site Footer
|
||||||
|
- Global footer on every page with © year, About link, Home link
|
||||||
|
|
||||||
|
### Consistent Container Width
|
||||||
|
- All pages use `max-w-5xl` for both header and body containers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (from knowledgebase/)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
npx prisma generate # Regenerate Prisma client after schema changes
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npx next build
|
||||||
|
|
||||||
|
# Extraction & seeding scripts are in extractor/ — see extractor/scripts/
|
||||||
|
|
||||||
|
# BFF API server (from ../spsays-bff/)
|
||||||
|
make run # Build + start Go API on :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verse JSON Schema (BG/SB/CC/SB1962/ISO/NOI)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"book_title": "Śrīmad-Bhāgavatam",
|
||||||
|
"canto": 1,
|
||||||
|
"chapter": 1,
|
||||||
|
"verse": 1,
|
||||||
|
"verse_end": null,
|
||||||
|
"reference": "SB 1.1.1",
|
||||||
|
"devanagari": "ॐ नमो भगवते वासुदेवाय\nजन्माद्यस्य यतो ...",
|
||||||
|
"bengali": null,
|
||||||
|
"transliteration": "om namo bhagavate vāsudevāya\njanmādy asya yato ...",
|
||||||
|
"synonyms": [
|
||||||
|
{ "word": "om", "meanings": ["O my Lord"] },
|
||||||
|
{ "word": "namaḥ", "meanings": ["offering my obeisances"] }
|
||||||
|
],
|
||||||
|
"translation": "<b>I offer my obeisances unto Lord Śrī Kṛṣṇa...</b>",
|
||||||
|
"purport": ["paragraph 1...", "paragraph 2..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prose Chapter JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"book": "ek",
|
||||||
|
"chapter": 1,
|
||||||
|
"title": "Choosing Human and Animal Lives",
|
||||||
|
"type": "chapter",
|
||||||
|
"paragraphs": ["<b>heading</b>", "body text...", "<div style=\"text-align:center\"><i>verse</i></div>"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intro Segment JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "preface",
|
||||||
|
"title": "Preface",
|
||||||
|
"type": "intro-segment",
|
||||||
|
"book": "ek",
|
||||||
|
"order": 2,
|
||||||
|
"paragraphs": ["paragraph 1...", "paragraph 2..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Essay JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "krishna-reservoir-of-pleasure",
|
||||||
|
"title": "Krishna The Reservoir Of Pleasure",
|
||||||
|
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||||
|
"year": 1966,
|
||||||
|
"group": "two-essays",
|
||||||
|
"order": 1,
|
||||||
|
"paragraphs": ["paragraph 1...", "paragraph 2..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Glossary JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Glossary of Vedic Terms",
|
||||||
|
"description": "A comprehensive glossary...",
|
||||||
|
"totalEntries": 2749,
|
||||||
|
"letters": ["A", "B", "C", ...],
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"term": "Ācārya",
|
||||||
|
"definition": "a spiritual master who teaches by his own example...",
|
||||||
|
"definition_html": "a spiritual master who teaches by his own example..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Glossary is browsed via `/glossary/{letter}` routes (A-Z). Each letter page shows terms grouped by base letter (diacritical variants mapped: Ā→A, Ś→S, Ṛ→R, etc.). Single entry view with prev/next navigation.
|
||||||
|
|
||||||
|
## Transcription JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "720911sb-los-angeles",
|
||||||
|
"name": "Śrīmad-Bhāgavatam 1.2.6",
|
||||||
|
"type": "lecture",
|
||||||
|
"date": "September 11, 1972",
|
||||||
|
"year": 1972,
|
||||||
|
"month": 9,
|
||||||
|
"day": 11,
|
||||||
|
"place": "Los Angeles",
|
||||||
|
"context": null,
|
||||||
|
"reference": "SB 1.2.6",
|
||||||
|
"durationMinutes": 45,
|
||||||
|
"participants": ["Prabhupāda", "Devotees"],
|
||||||
|
"audioUrl": "https://s3.prabhupadasays.com/sp-audio/...",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "So Kṛṣṇa consciousness movement...",
|
||||||
|
"format": "paragraph",
|
||||||
|
"alignment": "left",
|
||||||
|
"speaker": "Prabhupāda"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "<i>oṁ namo bhagavate...</i>",
|
||||||
|
"format": "verse",
|
||||||
|
"alignment": "center",
|
||||||
|
"speaker": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `"quote"` (translation/purport), `"heading"` (TEXT/TRANSLATION/PURPORT labels), `"note"` (editorial notes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
1. **MongoDB BSON wire corruption**: The MongoDB server occasionally corrupts large BSON documents. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time.
|
||||||
|
|
||||||
|
2. **SB verse numbering**: 5 duplicate verse reference tags in the HTML. Fixed by using the TEXT N header as authoritative.
|
||||||
|
|
||||||
|
3. **RTF routes gated**: `/rtfs` only accessible when `ALLOW_RTF=true`.
|
||||||
|
|
||||||
|
4. **`scripts/` excluded from tsconfig**: Prevents TypeScript build errors from seed scripts.
|
||||||
|
|
||||||
|
5. **CSS zoom for font scaling**: `zoom` property works in Chrome/Safari/Edge but may not work in older Firefox (< 126). A `transform: scale()` fallback is provided via `@supports not (zoom: 1.1)`.
|
||||||
|
|
||||||
|
6. **Password protection requires ACCESS_KEY at runtime**: The `PasswordGate` component checks the server at runtime via `/api/verify-access`. The env var must be available when the server is running, not just at build time.
|
||||||
|
|
||||||
|
7. **Transcription BSON fallback** — 1 document stores content as `contentJson` string due to BSON wire protocol limits; `getTranscription()` transparently parses it
|
||||||
|
|
||||||
|
8. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
|
||||||
|
|
||||||
|
9. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { buildOgMeta } from "@/lib/og";
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
BrainCircuit,
|
||||||
|
Blocks,
|
||||||
|
Circle,
|
||||||
|
CheckCircle2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const PHASES = [
|
||||||
|
{
|
||||||
|
title: "Phase I — The Vedabase",
|
||||||
|
status: "in-progress" 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 — 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 III — 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 Prabhupādā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 Prabhupādānugas — devotees 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 three 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 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">
|
||||||
|
{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>
|
||||||
|
{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">
|
||||||
|
<CheckCircle2 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { getBookMeta } from "@/lib/api";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
{ params }: { params: Promise<{ book: string }> }
|
{ params }: { params: Promise<{ book: string }> },
|
||||||
) {
|
) {
|
||||||
const { book } = await params;
|
const { book } = await params;
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book } });
|
const meta = await getBookMeta(book);
|
||||||
if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
return NextResponse.json({ book: meta.book, ...meta.data as Record<string, unknown> });
|
return NextResponse.json(meta);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
|
getSingleVerse,
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
getChapterVerseIndex,
|
||||||
|
getChapterVerses,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -9,8 +11,9 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { book } = await params;
|
const { book } = await params;
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const division = url.searchParams.get("division");
|
const division = url.searchParams.get("division") || null;
|
||||||
const chapterStr = url.searchParams.get("chapter");
|
const chapterStr = url.searchParams.get("chapter");
|
||||||
|
const verseStr = url.searchParams.get("verse");
|
||||||
const indexOnly = url.searchParams.get("indexOnly") === "true";
|
const indexOnly = url.searchParams.get("indexOnly") === "true";
|
||||||
const pageStr = url.searchParams.get("page");
|
const pageStr = url.searchParams.get("page");
|
||||||
const limitStr = url.searchParams.get("limit");
|
const limitStr = url.searchParams.get("limit");
|
||||||
@@ -23,54 +26,44 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chapter = parseInt(chapterStr);
|
const chapter = parseInt(chapterStr);
|
||||||
const where: Record<string, unknown> = { book, chapter };
|
|
||||||
|
|
||||||
if (book === "bg") {
|
const effectiveDivision = book === "bg" ? null : division;
|
||||||
where.division = null;
|
|
||||||
} else if (division) {
|
// --- Single verse mode: ?verse=N ---
|
||||||
where.division = division;
|
if (verseStr) {
|
||||||
|
const verseNum = parseInt(verseStr);
|
||||||
|
const verse = await getSingleVerse(
|
||||||
|
book,
|
||||||
|
effectiveDivision,
|
||||||
|
chapter,
|
||||||
|
verseNum,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!verse) {
|
||||||
|
return NextResponse.json({ error: "Verse not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(verse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Index-only mode: return lightweight verse list for the sidebar ---
|
// --- Index-only mode: return lightweight verse list for the sidebar ---
|
||||||
// Returns all verses but only the fields needed for navigation
|
|
||||||
if (indexOnly) {
|
if (indexOnly) {
|
||||||
const verses = await prisma.verse.findMany({
|
const verses = await getChapterVerseIndex(book, effectiveDivision, chapter);
|
||||||
where,
|
|
||||||
orderBy: { verse: "asc" },
|
|
||||||
select: {
|
|
||||||
verse: true,
|
|
||||||
verseEnd: true,
|
|
||||||
reference: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ verses, total: verses.length });
|
return NextResponse.json({ verses, total: verses.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Paginated mode: return full verse data in pages ---
|
// --- Paginated mode: return full verse data in pages ---
|
||||||
const page = Math.max(1, parseInt(pageStr || "1"));
|
const page = Math.max(1, parseInt(pageStr || "1"));
|
||||||
const limit = Math.min(
|
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
|
||||||
100,
|
|
||||||
Math.max(1, parseInt(limitStr || String(DEFAULT_PAGE_SIZE))),
|
|
||||||
);
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
|
|
||||||
const [verses, total] = await Promise.all([
|
const result = await getChapterVerses(
|
||||||
prisma.verse.findMany({
|
book,
|
||||||
where,
|
effectiveDivision,
|
||||||
orderBy: { verse: "asc" },
|
chapter,
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
}),
|
|
||||||
prisma.verse.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
verses,
|
|
||||||
total,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
totalPages: Math.ceil(total / limit),
|
);
|
||||||
hasMore: skip + verses.length < total,
|
|
||||||
});
|
return NextResponse.json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { getAllBooks } from "@/lib/api";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const metas = await prisma.bookMeta.findMany();
|
const books = await getAllBooks();
|
||||||
return NextResponse.json(metas.map((m) => ({ book: m.book, ...m.data as Record<string, unknown> })));
|
return NextResponse.json(books);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { getAllGlossaryEntries } from "@/lib/api";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const entries = await prisma.glossaryEntry.findMany({
|
const entries = await getAllGlossaryEntries();
|
||||||
orderBy: { term: "asc" },
|
|
||||||
});
|
|
||||||
return NextResponse.json(entries);
|
return NextResponse.json(entries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
// Cache font data at module level so they're only loaded once
|
||||||
|
let notoRegularData: Buffer | null = null;
|
||||||
|
let notoBoldData: Buffer | null = null;
|
||||||
|
|
||||||
|
async function loadFonts() {
|
||||||
|
const fontsDir = join(process.cwd(), "public/fonts/Noto_Sans/static");
|
||||||
|
|
||||||
|
if (!notoRegularData) {
|
||||||
|
notoRegularData = await readFile(join(fontsDir, "NotoSans-Regular.ttf"));
|
||||||
|
}
|
||||||
|
if (!notoBoldData) {
|
||||||
|
notoBoldData = await readFile(join(fontsDir, "NotoSans-Bold.ttf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { regular: notoRegularData, bold: notoBoldData };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const title = searchParams.get("title") || "Prabhupāda Says";
|
||||||
|
const detail = searchParams.get("detail") || "";
|
||||||
|
const rawQuote = searchParams.get("quote") || "";
|
||||||
|
|
||||||
|
// Fallback: if no quote provided, generate one from context
|
||||||
|
const quote =
|
||||||
|
rawQuote ||
|
||||||
|
(title === "Prabhupāda Says"
|
||||||
|
? "Browse the original, unedited books, letters, and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda — presented as they are."
|
||||||
|
: `Read ${title}${detail ? ` — ${detail}` : ""} by His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda`);
|
||||||
|
|
||||||
|
// Truncate for readability and to prevent overflow
|
||||||
|
const displayTitle = title.length > 60 ? title.slice(0, 57) + "..." : title;
|
||||||
|
const displayDetail =
|
||||||
|
detail.length > 80 ? detail.slice(0, 77) + "..." : detail;
|
||||||
|
const displayQuote =
|
||||||
|
quote.length > 220 ? quote.slice(0, 217) + "..." : quote;
|
||||||
|
|
||||||
|
// Load fonts and image
|
||||||
|
const fonts = await loadFonts();
|
||||||
|
|
||||||
|
const imgData = await readFile(
|
||||||
|
join(process.cwd(), "public/images/og-left-prabhupada-reading.png"),
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
const imgSrc = `data:image/png;base64,${imgData}`;
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: "#faf5f0",
|
||||||
|
fontFamily: "Noto Sans, sans-serif",
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side — image + branding */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 460,
|
||||||
|
height: 630,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Prabhupāda image — already contains quote mark and branding */}
|
||||||
|
<img
|
||||||
|
src={imgSrc}
|
||||||
|
width={420}
|
||||||
|
height={590}
|
||||||
|
style={{
|
||||||
|
objectFit: "contain",
|
||||||
|
objectPosition: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side — content */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: 50,
|
||||||
|
paddingRight: 60,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingLeft: 60,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#2d2418",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
display: "flex",
|
||||||
|
overflow: "hidden",
|
||||||
|
maxHeight: 104,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail (chapter, verse, etc.) */}
|
||||||
|
{displayDetail ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#5a4a3a",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
display: "flex",
|
||||||
|
overflow: "hidden",
|
||||||
|
maxHeight: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayDetail}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Quote text */}
|
||||||
|
{displayQuote ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
marginTop: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
maxHeight: 180,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#3d3228",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
display: "flex",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayQuote}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Read more */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#5a4a3a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Read more at
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#c47a5a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
prabhupadasays.org
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: "Noto Sans",
|
||||||
|
data: fonts.regular,
|
||||||
|
style: "normal" as const,
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Noto Sans",
|
||||||
|
data: fonts.bold,
|
||||||
|
style: "normal" as const,
|
||||||
|
weight: 700,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("OG image generation failed:", e);
|
||||||
|
return new Response("Failed to generate OG image", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import Link from "next/link";
|
export const dynamic = "force-dynamic";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getBookMeta, getChapterVerses, getChapterVerseIndex } from "@/lib/api";
|
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
|
||||||
|
|
||||||
type BgMeta = {
|
import type { Metadata } from "next";
|
||||||
book: string;
|
import { redirect } from "next/navigation";
|
||||||
title: string;
|
import { notFound } from "next/navigation";
|
||||||
author: string;
|
import { getChapterVerseIndex } from "@/lib/api";
|
||||||
totalChapters: number;
|
import { buildOgMeta } from "@/lib/og";
|
||||||
chapters: { number: number; title: string; totalVerses: number }[];
|
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({
|
export default async function ChapterPage({
|
||||||
params,
|
params,
|
||||||
@@ -18,56 +22,9 @@ export default async function ChapterPage({
|
|||||||
params: Promise<{ chapter: string }>;
|
params: Promise<{ chapter: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { chapter } = await params;
|
const { chapter } = await params;
|
||||||
const PAGE_SIZE = 20;
|
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
|
||||||
|
if (verseIndex.length === 0) {
|
||||||
const meta = await getBookMeta("bg");
|
notFound();
|
||||||
if (!meta) notFound();
|
}
|
||||||
|
redirect(`/books/bg/${chapter}/${verseIndex[0].verse}`);
|
||||||
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, firstPage] = await Promise.all([
|
|
||||||
getChapterVerseIndex("bg", null, parseInt(chapter)),
|
|
||||||
getChapterVerses("bg", null, parseInt(chapter), 1, PAGE_SIZE),
|
|
||||||
]);
|
|
||||||
if (verseIndex.length === 0) notFound();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<PageHeader sticky wide>
|
|
||||||
<Link
|
|
||||||
href="/books/bg"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
← Bhagavad-gītā
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
|
||||||
Chapter {chapter}: {title}
|
|
||||||
</h1>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div className="w-full md:max-w-5xl mx-auto flex">
|
|
||||||
<ChapterViewer
|
|
||||||
initialVerses={firstPage.verses}
|
|
||||||
hasMore={firstPage.hasMore}
|
|
||||||
totalPages={firstPage.totalPages}
|
|
||||||
book="bg"
|
|
||||||
division={null}
|
|
||||||
chapter={parseInt(chapter)}
|
|
||||||
limit={PAGE_SIZE}
|
|
||||||
showBengali={false}
|
|
||||||
verseIndex={verseIndex.map((v) => ({
|
|
||||||
verse: v.verse,
|
|
||||||
verseEnd: v.verseEnd ?? undefined,
|
|
||||||
reference: v.reference,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta } from "@/lib/api";
|
import { getBookMeta } from "@/lib/api";
|
||||||
|
import { ogForBook } from "@/lib/og";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
return ogForBook("bg");
|
||||||
|
}
|
||||||
|
|
||||||
type ChapterInfo = {
|
type ChapterInfo = {
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -37,7 +43,7 @@ export default async function BgPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-muted-foreground">{intro.author}</p>
|
<p className="mt-1 text-muted-foreground">{intro.author}</p>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<Card className="divide-y divide-border py-0">
|
<Card className="divide-y divide-border py-0">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
@@ -45,7 +51,7 @@ export default async function BgPage() {
|
|||||||
<li key={ch.number}>
|
<li key={ch.number}>
|
||||||
<Link
|
<Link
|
||||||
href={`/books/bg/${ch.number}`}
|
href={`/books/bg/${ch.number}`}
|
||||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-primary font-medium">
|
<span className="text-sm text-primary font-medium">
|
||||||
@@ -53,7 +59,7 @@ export default async function BgPage() {
|
|||||||
</span>
|
</span>
|
||||||
<p className="font-medium text-foreground">{ch.title}</p>
|
<p className="font-medium text-foreground">{ch.title}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
<ExtractedVerseCount count={ch.totalVerses} />
|
<ExtractedVerseCount count={ch.totalVerses} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
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/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/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import Link from "next/link";
|
import type { Metadata } from "next";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { redirect } from "next/navigation";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getChapterVerses, getChapterVerseIndex } from "@/lib/api";
|
import { getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { CC_LILAS } from "@/lib/constants";
|
||||||
|
import { buildOgMeta } from "@/lib/og";
|
||||||
|
|
||||||
type ChapterInfo = {
|
export async function generateMetadata({
|
||||||
number: number;
|
params,
|
||||||
title: string;
|
}: {
|
||||||
totalVerses: number;
|
params: Promise<{ lila: string; chapter: string }>;
|
||||||
};
|
}): Promise<Metadata> {
|
||||||
|
const { lila, chapter } = await params;
|
||||||
type LilaInfo = {
|
const lilaName = CC_LILAS[lila] || lila;
|
||||||
key: string;
|
return buildOgMeta({
|
||||||
name: string;
|
title: "Śrī Caitanya-caritāmṛta",
|
||||||
totalChapters: number;
|
detail: `${lilaName}, Chapter ${chapter}`,
|
||||||
totalVerses: number;
|
});
|
||||||
chapters: ChapterInfo[];
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default async function CcChapterPage({
|
export default async function CcChapterPage({
|
||||||
params,
|
params,
|
||||||
@@ -24,57 +24,9 @@ export default async function CcChapterPage({
|
|||||||
params: Promise<{ lila: string; chapter: string }>;
|
params: Promise<{ lila: string; chapter: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { lila, chapter } = await params;
|
const { lila, chapter } = await params;
|
||||||
const PAGE_SIZE = 20;
|
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
|
||||||
|
if (verseIndex.length === 0) {
|
||||||
const meta = await getBookMeta("cc");
|
notFound();
|
||||||
if (!meta) notFound();
|
}
|
||||||
|
redirect(`/books/cc/${lila}/${chapter}/${verseIndex[0].verse}`);
|
||||||
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, firstPage] = await Promise.all([
|
|
||||||
getChapterVerseIndex("cc", lila, parseInt(chapter)),
|
|
||||||
getChapterVerses("cc", lila, parseInt(chapter), 1, PAGE_SIZE),
|
|
||||||
]);
|
|
||||||
if (verseIndex.length === 0) notFound();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<PageHeader sticky wide>
|
|
||||||
<Link
|
|
||||||
href={`/books/cc/${lila}`}
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
← {lilaInfo.name}
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
|
||||||
Chapter {chapter}: {chapterInfo.title}
|
|
||||||
</h1>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div className="w-full md:max-w-5xl mx-auto flex">
|
|
||||||
<ChapterViewer
|
|
||||||
initialVerses={firstPage.verses}
|
|
||||||
hasMore={firstPage.hasMore}
|
|
||||||
totalPages={firstPage.totalPages}
|
|
||||||
book="cc"
|
|
||||||
division={lila}
|
|
||||||
chapter={parseInt(chapter)}
|
|
||||||
limit={PAGE_SIZE}
|
|
||||||
showBengali={true}
|
|
||||||
verseIndex={verseIndex.map((v) => ({
|
|
||||||
verse: v.verse,
|
|
||||||
verseEnd: v.verseEnd ?? undefined,
|
|
||||||
reference: v.reference,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ExtractedVerseCount } from "../../extracted-verse-count";
|
import { ExtractedVerseCount } from "../../extracted-verse-count";
|
||||||
import { getBookMeta } from "@/lib/api";
|
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 = {
|
type ChapterInfo = {
|
||||||
number: number;
|
number: number;
|
||||||
@@ -54,7 +71,7 @@ export default async function LilaPage({
|
|||||||
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
|
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
|
||||||
</p>
|
</p>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<Card className="divide-y divide-border">
|
<Card className="divide-y divide-border">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
@@ -62,7 +79,7 @@ export default async function LilaPage({
|
|||||||
<li key={ch.number}>
|
<li key={ch.number}>
|
||||||
<Link
|
<Link
|
||||||
href={`/books/cc/${lila}/${ch.number}`}
|
href={`/books/cc/${lila}/${ch.number}`}
|
||||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-primary font-medium">
|
<span className="text-sm text-primary font-medium">
|
||||||
@@ -70,7 +87,7 @@ export default async function LilaPage({
|
|||||||
</span>
|
</span>
|
||||||
<p className="font-medium text-foreground">{ch.title}</p>
|
<p className="font-medium text-foreground">{ch.title}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
<ExtractedVerseCount count={ch.totalVerses} />
|
<ExtractedVerseCount count={ch.totalVerses} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getBookMeta } from "@/lib/api";
|
import { getBookMeta } from "@/lib/api";
|
||||||
|
import { ogForBook } from "@/lib/og";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -19,6 +21,10 @@ type LilaInfo = {
|
|||||||
chapters: ChapterInfo[];
|
chapters: ChapterInfo[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
return ogForBook("cc");
|
||||||
|
}
|
||||||
|
|
||||||
export default async function CcPage() {
|
export default async function CcPage() {
|
||||||
const meta = await getBookMeta("cc");
|
const meta = await getBookMeta("cc");
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
@@ -45,7 +51,7 @@ export default async function CcPage() {
|
|||||||
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
|
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
|
||||||
<p className="mt-1 text-muted-foreground">{author}</p>
|
<p className="mt-1 text-muted-foreground">{author}</p>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<Card className="divide-y divide-border">
|
<Card className="divide-y divide-border">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
@@ -58,7 +64,7 @@ export default async function CcPage() {
|
|||||||
<li key={lila.key}>
|
<li key={lila.key}>
|
||||||
<Link
|
<Link
|
||||||
href={`/books/cc/${lila.key}`}
|
href={`/books/cc/${lila.key}`}
|
||||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="secondary" className="mb-1">
|
<Badge variant="secondary" className="mb-1">
|
||||||
@@ -68,7 +74,7 @@ export default async function CcPage() {
|
|||||||
{lila.totalChapters} chapters
|
{lila.totalChapters} chapters
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
<ExtractedVerseCount
|
<ExtractedVerseCount
|
||||||
count={totalVerses}
|
count={totalVerses}
|
||||||
label="extracted verses"
|
label="extracted verses"
|
||||||
|
|||||||
+353
-362
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useMemo, useEffect, useState } from "react";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -31,15 +34,6 @@ type Verse = {
|
|||||||
purport: string[];
|
purport: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PaginatedResponse = {
|
|
||||||
verses: Verse[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalPages: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VerseIndexEntry = {
|
type VerseIndexEntry = {
|
||||||
verse: number;
|
verse: number;
|
||||||
verseEnd?: number;
|
verseEnd?: number;
|
||||||
@@ -51,92 +45,245 @@ type VerseIndexEntry = {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type ChapterViewerProps = {
|
type ChapterViewerProps = {
|
||||||
initialVerses: Verse[];
|
/** The first verse (SSR'd), shown before any client-side fetch */
|
||||||
hasMore: boolean;
|
initialVerse: Verse;
|
||||||
totalPages: number;
|
/** The verse number of the initial verse */
|
||||||
book: string;
|
initialVerseNum: number;
|
||||||
division: string | null;
|
/** Lightweight index of ALL verses in the chapter (for the sidebar) */
|
||||||
chapter: number;
|
|
||||||
limit: number;
|
|
||||||
showBengali?: boolean;
|
|
||||||
verseIndex: VerseIndexEntry[];
|
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
|
// ChapterViewer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* ChapterViewer — a single "use client" component that owns both the sidebar
|
|
||||||
* verse index and the main verse list with infinite scroll + jump-to-verse.
|
|
||||||
*
|
|
||||||
* This avoids the server→client boundary issue of passing functions as props
|
|
||||||
* (render props / callbacks can't cross that boundary).
|
|
||||||
*
|
|
||||||
* The server component only passes serialisable data:
|
|
||||||
* - initialVerses (page 1 of full verse data, SSR'd)
|
|
||||||
* - verseIndex (lightweight list of ALL verse numbers/references)
|
|
||||||
* - pagination metadata (hasMore, totalPages, book, division, chapter, limit)
|
|
||||||
*/
|
|
||||||
export function ChapterViewer({
|
export function ChapterViewer({
|
||||||
initialVerses,
|
initialVerse,
|
||||||
hasMore: initialHasMore,
|
initialVerseNum,
|
||||||
totalPages: initialTotalPages,
|
verseIndex,
|
||||||
book,
|
book,
|
||||||
division,
|
division,
|
||||||
chapter,
|
chapter,
|
||||||
limit,
|
|
||||||
showBengali = false,
|
showBengali = false,
|
||||||
verseIndex,
|
basePath,
|
||||||
|
mobileNavGroups,
|
||||||
|
mobileNavBookTitle,
|
||||||
}: ChapterViewerProps) {
|
}: ChapterViewerProps) {
|
||||||
// ------ Verse data state ------
|
const router = useRouter();
|
||||||
const [verses, setVerses] = useState<Verse[]>(initialVerses);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [hasMorePages, setHasMorePages] = useState(initialHasMore);
|
|
||||||
const [highestPageFetched, setHighestPageFetched] = useState(1);
|
|
||||||
const [totalPagesKnown, setTotalPagesKnown] = useState(initialTotalPages);
|
|
||||||
const fetchingRef = useRef(false);
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// ------ Sidebar state ------
|
// Verse cache: verseNum → Verse data
|
||||||
const [activeVerse, setActiveVerse] = useState<number | null>(null);
|
const [cache, setCache] = useState<Map<number, Verse>>(() => {
|
||||||
const [loadedVerses, setLoadedVerses] = useState<Set<number>>(() => {
|
const map = new Map<number, Verse>();
|
||||||
const set = new Set<number>();
|
map.set(initialVerseNum, initialVerse);
|
||||||
for (const v of initialVerses) set.add(v.verse);
|
return map;
|
||||||
return set;
|
|
||||||
});
|
});
|
||||||
const [pendingVerse, setPendingVerse] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Reset when route changes (different chapter)
|
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(() => {
|
useEffect(() => {
|
||||||
setVerses(initialVerses);
|
const map = new Map<number, Verse>();
|
||||||
setHasMorePages(initialHasMore);
|
map.set(initialVerseNum, initialVerse);
|
||||||
setHighestPageFetched(1);
|
setCache(map);
|
||||||
setTotalPagesKnown(initialTotalPages);
|
}, [initialVerse, initialVerseNum]);
|
||||||
const set = new Set<number>();
|
|
||||||
for (const v of initialVerses) set.add(v.verse);
|
|
||||||
setLoadedVerses(set);
|
|
||||||
setPendingVerse(null);
|
|
||||||
setActiveVerse(null);
|
|
||||||
}, [initialVerses, initialHasMore, initialTotalPages]);
|
|
||||||
|
|
||||||
// ---- Update loadedVerses whenever verses array changes ----
|
// Fetch a verse if it's not in the cache
|
||||||
useEffect(() => {
|
const fetchVerse = useCallback(
|
||||||
const set = new Set<number>();
|
async (verseNum: number) => {
|
||||||
for (const v of verses) set.add(v.verse);
|
if (cache.has(verseNum)) return cache.get(verseNum)!;
|
||||||
setLoadedVerses(set);
|
|
||||||
}, [verses]);
|
|
||||||
|
|
||||||
// =====================================================================
|
setLoading(true);
|
||||||
// Fetching
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
const fetchPage = useCallback(
|
|
||||||
async (page: number): Promise<PaginatedResponse | null> => {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
chapter: String(chapter),
|
chapter: String(chapter),
|
||||||
page: String(page),
|
verse: String(verseNum),
|
||||||
limit: String(limit),
|
|
||||||
});
|
});
|
||||||
if (division !== null) {
|
if (division !== null) {
|
||||||
params.set("division", division);
|
params.set("division", division);
|
||||||
@@ -145,331 +292,175 @@ export function ChapterViewer({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/books/${book}/verses?${params}`);
|
const res = await fetch(`/api/books/${book}/verses?${params}`);
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
return await res.json();
|
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) {
|
} catch (err) {
|
||||||
console.error(`Failed to fetch page ${page}:`, err);
|
console.error("Failed to fetch verse:", err);
|
||||||
return null;
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
[book, division, chapter, limit],
|
[cache, book, division, chapter],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch the next single page (for infinite scroll)
|
// Navigate to a verse — update the URL path and fetch if needed
|
||||||
const fetchNextPage = useCallback(async () => {
|
const navigateTo = useCallback(
|
||||||
if (fetchingRef.current || !hasMorePages) return;
|
async (verseNum: number) => {
|
||||||
fetchingRef.current = true;
|
// Prefetch the verse data before navigating
|
||||||
setLoading(true);
|
if (!cache.has(verseNum)) {
|
||||||
|
await fetchVerse(verseNum);
|
||||||
|
}
|
||||||
|
|
||||||
const nextPage = highestPageFetched + 1;
|
// Navigate to the new path-based URL
|
||||||
const data = await fetchPage(nextPage);
|
router.push(`${basePath}/${verseNum}`, { scroll: false });
|
||||||
|
|
||||||
if (data) {
|
// Scroll to top of the verse
|
||||||
setVerses((prev) => [...prev, ...data.verses]);
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
setHasMorePages(data.hasMore);
|
},
|
||||||
setHighestPageFetched(nextPage);
|
[basePath, router, cache, fetchVerse],
|
||||||
setTotalPagesKnown(data.totalPages);
|
);
|
||||||
|
|
||||||
|
// Fetch current verse on mount/URL change if not cached
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cache.has(currentVerseNum)) {
|
||||||
|
fetchVerse(currentVerseNum);
|
||||||
}
|
}
|
||||||
|
}, [currentVerseNum, cache, fetchVerse]);
|
||||||
|
|
||||||
setLoading(false);
|
// Prefetch adjacent verses
|
||||||
fetchingRef.current = false;
|
useEffect(() => {
|
||||||
}, [hasMorePages, highestPageFetched, fetchPage]);
|
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]);
|
||||||
|
|
||||||
// Fetch all pages up to the one containing targetVerse
|
// Keyboard navigation
|
||||||
const requestVerse = useCallback(
|
useEffect(() => {
|
||||||
async (targetVerse: number) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
// Already loaded?
|
if (
|
||||||
if (verses.some((v) => v.verse === targetVerse)) {
|
e.target instanceof HTMLInputElement ||
|
||||||
// Just scroll to it
|
e.target instanceof HTMLTextAreaElement
|
||||||
const el = document.getElementById(`verse-${targetVerse}`);
|
)
|
||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
return;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchingRef.current) return;
|
|
||||||
fetchingRef.current = true;
|
|
||||||
setLoading(true);
|
|
||||||
setPendingVerse(targetVerse);
|
|
||||||
|
|
||||||
let currentHighest = highestPageFetched;
|
|
||||||
const allNewVerses: Verse[] = [];
|
|
||||||
let found = false;
|
|
||||||
let moreRemaining = hasMorePages;
|
|
||||||
|
|
||||||
while (!found && currentHighest < totalPagesKnown) {
|
|
||||||
const nextPage = currentHighest + 1;
|
|
||||||
const data = await fetchPage(nextPage);
|
|
||||||
|
|
||||||
if (!data || data.verses.length === 0) break;
|
|
||||||
|
|
||||||
allNewVerses.push(...data.verses);
|
|
||||||
currentHighest = nextPage;
|
|
||||||
moreRemaining = data.hasMore;
|
|
||||||
setTotalPagesKnown(data.totalPages);
|
|
||||||
|
|
||||||
if (data.verses.some((v) => v.verse === targetVerse)) {
|
|
||||||
found = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allNewVerses.length > 0) {
|
|
||||||
setVerses((prev) => [...prev, ...allNewVerses]);
|
|
||||||
setHighestPageFetched(currentHighest);
|
|
||||||
setHasMorePages(moreRemaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
fetchingRef.current = false;
|
|
||||||
// pendingVerse will trigger scroll via the effect below
|
|
||||||
},
|
|
||||||
[verses, highestPageFetched, hasMorePages, totalPagesKnown, fetchPage],
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Scroll to pending verse once it appears in the DOM
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingVerse === null) return;
|
|
||||||
if (!loadedVerses.has(pendingVerse)) return;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
const el = document.getElementById(`verse-${pendingVerse}`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
setPendingVerse(null);
|
|
||||||
}, 50);
|
|
||||||
});
|
|
||||||
}, [loadedVerses, pendingVerse]);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Infinite scroll observer (bottom sentinel)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sentinel = sentinelRef.current;
|
|
||||||
if (!sentinel) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0]?.isIntersecting && hasMorePages && !fetchingRef.current) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "600px" },
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [fetchNextPage, hasMorePages]);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Active verse scroll tracking (IntersectionObserver + MutationObserver)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const intersectionObserver = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
const num = parseInt(entry.target.id.replace("verse-", ""));
|
|
||||||
if (!isNaN(num)) setActiveVerse(num);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "-20% 0px -60% 0px" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const observeAll = () => {
|
|
||||||
const els = document.querySelectorAll("[id^='verse-']");
|
|
||||||
els.forEach((el) => intersectionObserver.observe(el));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
observeAll();
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [currentIdx, verseIndex, navigateTo]);
|
||||||
|
|
||||||
// Watch for new verse cards from infinite scroll
|
const goPrev = useCallback(() => {
|
||||||
const mutationObserver = new MutationObserver((mutations) => {
|
if (currentIdx > 0) navigateTo(verseIndex[currentIdx - 1].verse);
|
||||||
let hasNew = false;
|
}, [currentIdx, verseIndex, navigateTo]);
|
||||||
for (const mutation of mutations) {
|
|
||||||
for (const node of mutation.addedNodes) {
|
|
||||||
if (
|
|
||||||
node instanceof HTMLElement &&
|
|
||||||
(node.id?.startsWith("verse-") ||
|
|
||||||
node.querySelector?.("[id^='verse-']"))
|
|
||||||
) {
|
|
||||||
hasNew = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasNew) break;
|
|
||||||
}
|
|
||||||
if (hasNew) observeAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
const main = document.querySelector("main");
|
const goNext = useCallback(() => {
|
||||||
if (main) {
|
if (currentIdx < verseIndex.length - 1)
|
||||||
mutationObserver.observe(main, { childList: true, subtree: true });
|
navigateTo(verseIndex[currentIdx + 1].verse);
|
||||||
}
|
}, [currentIdx, verseIndex, navigateTo]);
|
||||||
|
|
||||||
return () => {
|
|
||||||
intersectionObserver.disconnect();
|
|
||||||
mutationObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Sidebar click handler
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
const handleIndexClick = useCallback(
|
|
||||||
(e: React.MouseEvent, verseNum: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const el = document.getElementById(`verse-${verseNum}`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not loaded — request it
|
|
||||||
setPendingVerse(verseNum);
|
|
||||||
requestVerse(verseNum);
|
|
||||||
},
|
|
||||||
[requestVerse],
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Render
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ---- Sidebar: Verse Index ---- */}
|
{/* ---- Sidebar: Verse Index ---- */}
|
||||||
<aside className="hidden lg:block w-48 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
<aside className="hidden lg:block w-64 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">
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||||
Verses
|
Verses
|
||||||
</h2>
|
</h2>
|
||||||
<nav className="grid grid-cols-3 gap-0.5">
|
<nav className="grid grid-cols-3 gap-1">
|
||||||
{verseIndex.map((v) => {
|
{verseIndex.map((v) => {
|
||||||
const isLoaded = loadedVerses.has(v.verse);
|
const isActive = currentVerseNum === v.verse;
|
||||||
const isActive = activeVerse === v.verse;
|
const label = v.verseEnd
|
||||||
const isPending = pendingVerse === v.verse;
|
? `${v.verse}–${v.verseEnd}`
|
||||||
|
: String(v.verse);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={v.verse}
|
key={v.verse}
|
||||||
href={`#verse-${v.verse}`}
|
href={`${basePath}/${v.verse}`}
|
||||||
onClick={(e) => handleIndexClick(e, v.verse)}
|
onClick={(e) => {
|
||||||
className={`px-2 py-1 rounded-md text-sm text-center transition-colors ${
|
e.preventDefault();
|
||||||
isPending
|
navigateTo(v.verse);
|
||||||
? "bg-primary/20 text-primary animate-pulse font-medium"
|
}}
|
||||||
: isActive
|
className={`px-2 py-1 rounded-md text-sm tabular-nums transition-colors ${
|
||||||
? "bg-primary/10 text-primary font-medium"
|
isActive
|
||||||
: isLoaded
|
? "bg-primary/10 text-primary font-medium"
|
||||||
? "text-muted-foreground hover:bg-muted hover:text-foreground"
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{v.verseEnd ? `${v.verse}–${v.verseEnd}` : v.verse}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ---- Main: Verse Cards ---- */}
|
{/* ---- Main: Single Verse ---- */}
|
||||||
<main className="flex-1 min-w-0 px-6 py-8 space-y-12">
|
<main className="flex-1 min-w-0 px-6 py-8 space-y-6">
|
||||||
{verses.map((verse) => (
|
{/* Top navigation */}
|
||||||
<Card
|
<VerseNav
|
||||||
key={`${verse.book}-${verse.division}-${verse.chapter}-${verse.verse}`}
|
currentIdx={currentIdx}
|
||||||
id={`verse-${verse.verse}`}
|
total={verseIndex.length}
|
||||||
className="scroll-mt-24"
|
onPrev={goPrev}
|
||||||
>
|
onNext={goNext}
|
||||||
<CardHeader className="border-b border-border bg-muted/50 py-3">
|
verseIndex={verseIndex}
|
||||||
<Badge variant="secondary">{verse.reference}</Badge>
|
mobileNavGroups={mobileNavGroups}
|
||||||
</CardHeader>
|
currentVerseSlug={String(currentVerseNum)}
|
||||||
|
mobileNavBookTitle={mobileNavBookTitle}
|
||||||
|
/>
|
||||||
|
|
||||||
<CardContent className="space-y-5 pt-5">
|
{/* Verse content */}
|
||||||
{/* Bengali (CC only) */}
|
{loading && !currentVerse ? (
|
||||||
{showBengali && verse.bengali && (
|
<div className="flex items-center justify-center py-24 text-muted-foreground gap-2">
|
||||||
<div className="text-center">
|
<Loader2 className="size-5 animate-spin" />
|
||||||
<p className="text-xl leading-relaxed whitespace-pre-line font-serif text-green-800 dark:text-green-400">
|
<span>Loading verse…</span>
|
||||||
{verse.bengali}
|
</div>
|
||||||
</p>
|
) : currentVerse ? (
|
||||||
</div>
|
<VerseCard verse={currentVerse} showBengali={showBengali} />
|
||||||
)}
|
) : (
|
||||||
|
<div className="text-center py-24 text-muted-foreground">
|
||||||
{/* Devanagari */}
|
Verse not found.
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xl leading-relaxed text-red-800 dark:text-red-400 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-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
|
||||||
Synonyms
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-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-xs 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-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
|
||||||
Purport
|
|
||||||
</h3>
|
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed space-y-3">
|
|
||||||
{verse.purport.map((p, i) => (
|
|
||||||
<p key={i} dangerouslySetInnerHTML={{ __html: p }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Sentinel for infinite scroll */}
|
|
||||||
<div ref={sentinelRef} className="h-1" />
|
|
||||||
|
|
||||||
{/* Loading indicator */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground gap-2">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
<span className="text-sm">Loading more verses…</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom navigation */}
|
||||||
|
<VerseNav
|
||||||
|
currentIdx={currentIdx}
|
||||||
|
total={verseIndex.length}
|
||||||
|
onPrev={goPrev}
|
||||||
|
onNext={goNext}
|
||||||
|
verseIndex={verseIndex}
|
||||||
|
mobileNavGroups={mobileNavGroups}
|
||||||
|
currentVerseSlug={String(currentVerseNum)}
|
||||||
|
mobileNavBookTitle={mobileNavBookTitle}
|
||||||
|
/>
|
||||||
</main>
|
</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,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/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/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+67
-52
@@ -1,22 +1,24 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ExtractedVerseCount } from "./extracted-verse-count";
|
|
||||||
import { getAllBooks, type BookMeta } from "@/lib/api";
|
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 ChapterEntry = { totalVerses: number };
|
||||||
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
|
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
|
||||||
type LilaEntry = { totalChapters: number; chapters: ChapterEntry[] };
|
type LilaEntry = { totalChapters: number; chapters: ChapterEntry[] };
|
||||||
|
|
||||||
interface BgMeta extends BookMeta {
|
interface ChapterBookMeta extends BookMeta {
|
||||||
totalChapters: number;
|
totalChapters: number;
|
||||||
chapters: ChapterEntry[];
|
chapters: ChapterEntry[];
|
||||||
}
|
}
|
||||||
@@ -31,15 +33,6 @@ interface CcMeta extends BookMeta {
|
|||||||
lilas: LilaEntry[];
|
lilas: LilaEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type BookInfo = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
description: string;
|
|
||||||
totalVerses: number;
|
|
||||||
href: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
||||||
const books: BookInfo[] = [];
|
const books: BookInfo[] = [];
|
||||||
|
|
||||||
@@ -49,7 +42,7 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
|||||||
const author = data.author as string;
|
const author = data.author as string;
|
||||||
|
|
||||||
if (slug === "bg") {
|
if (slug === "bg") {
|
||||||
const bg = data as BgMeta;
|
const bg = data as ChapterBookMeta;
|
||||||
const totalVerses = bg.chapters.reduce(
|
const totalVerses = bg.chapters.reduce(
|
||||||
(sum, ch) => sum + ch.totalVerses,
|
(sum, ch) => sum + ch.totalVerses,
|
||||||
0,
|
0,
|
||||||
@@ -59,9 +52,28 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
|||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
description: `${bg.totalChapters} chapters`,
|
description: `${bg.totalChapters} chapters`,
|
||||||
|
contentDescription:
|
||||||
|
"The essence of Vedic knowledge — Lord Kṛṣṇa's teachings to Arjuna on the battlefield of Kurukṣetra.",
|
||||||
totalVerses,
|
totalVerses,
|
||||||
href: "/books/bg",
|
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") {
|
} else if (slug === "sb") {
|
||||||
const sb = data as SbMeta;
|
const sb = data as SbMeta;
|
||||||
const totalChapters = sb.cantos.reduce(
|
const totalChapters = sb.cantos.reduce(
|
||||||
@@ -77,6 +89,8 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
|||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
description: `${sb.totalCantos} cantos, ${totalChapters} chapters`,
|
description: `${sb.totalCantos} cantos, ${totalChapters} chapters`,
|
||||||
|
contentDescription:
|
||||||
|
"The ripened fruit of Vedic literature — the science of God in 18,000 verses.",
|
||||||
totalVerses,
|
totalVerses,
|
||||||
href: "/books/sb",
|
href: "/books/sb",
|
||||||
});
|
});
|
||||||
@@ -95,9 +109,38 @@ function buildBookList(allBooks: BookMeta[]): BookInfo[] {
|
|||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
description: `${cc.totalLilas} līlās, ${totalChapters} chapters`,
|
description: `${cc.totalLilas} līlās, ${totalChapters} chapters`,
|
||||||
|
contentDescription:
|
||||||
|
"The life and teachings of Lord Caitanya Mahāprabhu — the most complete theistic science.",
|
||||||
totalVerses,
|
totalVerses,
|
||||||
href: "/books/cc",
|
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}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,40 +162,12 @@ export default async function BooksPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Books</h1>
|
<h1 className="mt-3 text-3xl font-bold text-foreground">Books</h1>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">
|
||||||
Structured verse-by-verse editions with Devanagari, transliteration,
|
Browse the books and teachings of Śrīla Prabhupāda
|
||||||
synonyms, translation & purports
|
|
||||||
</p>
|
</p>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<div className="grid gap-4">
|
<BooksGrid books={books} />
|
||||||
{books.map((book) => (
|
|
||||||
<Link key={book.slug} href={book.href} className="block">
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"transition-all hover:shadow-md hover:ring-foreground/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl font-bold">
|
|
||||||
{book.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{book.author}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-3 text-sm">
|
|
||||||
<Badge variant="secondary">{book.description}</Badge>
|
|
||||||
<ExtractedVerseCount
|
|
||||||
count={book.totalVerses}
|
|
||||||
label="extracted verses"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
import Link from "next/link";
|
import type { Metadata } from "next";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { redirect } from "next/navigation";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getChapterVerses, getChapterVerseIndex } from "@/lib/api";
|
import { getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { buildOgMeta } from "@/lib/og";
|
||||||
|
|
||||||
type ChapterInfo = {
|
export async function generateMetadata({
|
||||||
number: number;
|
params,
|
||||||
title: string;
|
}: {
|
||||||
totalVerses: number;
|
params: Promise<{ canto: string; chapter: string }>;
|
||||||
};
|
}): Promise<Metadata> {
|
||||||
|
const { canto, chapter } = await params;
|
||||||
type CantoInfo = {
|
return buildOgMeta({
|
||||||
number: number;
|
title: "Śrīmad-Bhāgavatam",
|
||||||
title: string;
|
detail: `Canto ${canto}, Chapter ${chapter}`,
|
||||||
totalChapters: number;
|
});
|
||||||
chapters: ChapterInfo[];
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default async function SbChapterPage({
|
export default async function SbChapterPage({
|
||||||
params,
|
params,
|
||||||
@@ -23,57 +22,9 @@ export default async function SbChapterPage({
|
|||||||
params: Promise<{ canto: string; chapter: string }>;
|
params: Promise<{ canto: string; chapter: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { canto, chapter } = await params;
|
const { canto, chapter } = await params;
|
||||||
const PAGE_SIZE = 20;
|
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
|
||||||
|
if (verseIndex.length === 0) {
|
||||||
const meta = await getBookMeta("sb");
|
notFound();
|
||||||
if (!meta) notFound();
|
}
|
||||||
|
redirect(`/books/sb/${canto}/${chapter}/${verseIndex[0].verse}`);
|
||||||
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, firstPage] = await Promise.all([
|
|
||||||
getChapterVerseIndex("sb", canto, parseInt(chapter)),
|
|
||||||
getChapterVerses("sb", canto, parseInt(chapter), 1, PAGE_SIZE),
|
|
||||||
]);
|
|
||||||
if (verseIndex.length === 0) notFound();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<PageHeader sticky wide>
|
|
||||||
<Link
|
|
||||||
href={`/books/sb/${canto}`}
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
← Canto {cantoInfo.number}: {cantoInfo.title}
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-2 text-2xl font-bold text-foreground">
|
|
||||||
Chapter {chapter}: {chapterInfo.title}
|
|
||||||
</h1>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div className="w-full md:max-w-5xl mx-auto flex">
|
|
||||||
<ChapterViewer
|
|
||||||
initialVerses={firstPage.verses}
|
|
||||||
hasMore={firstPage.hasMore}
|
|
||||||
totalPages={firstPage.totalPages}
|
|
||||||
book="sb"
|
|
||||||
division={canto}
|
|
||||||
chapter={parseInt(chapter)}
|
|
||||||
limit={PAGE_SIZE}
|
|
||||||
showBengali={false}
|
|
||||||
verseIndex={verseIndex.map((v) => ({
|
|
||||||
verse: v.verse,
|
|
||||||
verseEnd: v.verseEnd ?? undefined,
|
|
||||||
reference: v.reference,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ExtractedVerseCount } from "../../extracted-verse-count";
|
import { ExtractedVerseCount } from "../../extracted-verse-count";
|
||||||
import { getBookMeta } from "@/lib/api";
|
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 = {
|
type ChapterInfo = {
|
||||||
number: number;
|
number: number;
|
||||||
@@ -54,7 +67,7 @@ export default async function CantoPage({
|
|||||||
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
|
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
|
||||||
</p>
|
</p>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<Card className="divide-y divide-border">
|
<Card className="divide-y divide-border">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
@@ -62,7 +75,7 @@ export default async function CantoPage({
|
|||||||
<li key={ch.number}>
|
<li key={ch.number}>
|
||||||
<Link
|
<Link
|
||||||
href={`/books/sb/${cantoNum}/${ch.number}`}
|
href={`/books/sb/${cantoNum}/${ch.number}`}
|
||||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-primary font-medium">
|
<span className="text-sm text-primary font-medium">
|
||||||
@@ -70,7 +83,7 @@ export default async function CantoPage({
|
|||||||
</span>
|
</span>
|
||||||
<p className="font-medium text-foreground">{ch.title}</p>
|
<p className="font-medium text-foreground">{ch.title}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
<ExtractedVerseCount count={ch.totalVerses} />
|
<ExtractedVerseCount count={ch.totalVerses} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ExtractedVerseCount } from "../extracted-verse-count";
|
import { ExtractedVerseCount } from "../extracted-verse-count";
|
||||||
import { getBookMeta } from "@/lib/api";
|
import { getBookMeta } from "@/lib/api";
|
||||||
|
import { ogForBook } from "@/lib/og";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
return ogForBook("sb");
|
||||||
|
}
|
||||||
|
|
||||||
type ChapterInfo = {
|
type ChapterInfo = {
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -45,7 +51,7 @@ export default async function SbPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-muted-foreground">{intro.author}</p>
|
<p className="mt-1 text-muted-foreground">{intro.author}</p>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<Card className="divide-y divide-border">
|
<Card className="divide-y divide-border">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
@@ -58,7 +64,7 @@ export default async function SbPage() {
|
|||||||
<li key={canto.number}>
|
<li key={canto.number}>
|
||||||
<Link
|
<Link
|
||||||
href={`/books/sb/${canto.number}`}
|
href={`/books/sb/${canto.number}`}
|
||||||
className="flex items-center justify-between px-5 py-4 hover:bg-muted transition-colors"
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="secondary" className="mb-1">
|
<Badge variant="secondary" className="mb-1">
|
||||||
@@ -68,7 +74,7 @@ export default async function SbPage() {
|
|||||||
{canto.title}
|
{canto.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground shrink-0 ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{canto.totalChapters} chapters ·{" "}
|
{canto.totalChapters} chapters ·{" "}
|
||||||
<ExtractedVerseCount
|
<ExtractedVerseCount
|
||||||
count={totalVerses}
|
count={totalVerses}
|
||||||
|
|||||||
@@ -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/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,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..."
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.1 KiB |
+209
-114
@@ -5,126 +5,221 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Light mode — "Aged Parchment" ── */
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.957 0.012 79.8);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.307 0.034 54.8);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.974 0.01 81.8);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.307 0.034 54.8);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.974 0.01 81.8);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.307 0.034 54.8);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.652 0.132 81.6);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.994 0.008 91.5);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.899 0.03 80.7);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.307 0.034 54.8);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.922 0.023 82.1);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.571 0.053 72.7);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.922 0.023 82.1);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.307 0.034 54.8);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.862 0.035 82.9);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.862 0.035 82.9);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.729 0.089 81.2);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.652 0.132 81.6);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.571 0.053 72.7);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.729 0.089 81.2);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.44 0.04 60);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.307 0.034 54.8);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.974 0.01 81.8);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.307 0.034 54.8);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.652 0.132 81.6);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.994 0.008 91.5);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.922 0.023 82.1);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.307 0.034 54.8);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.862 0.035 82.9);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.729 0.089 81.2);
|
||||||
|
|
||||||
|
/* Reading settings defaults */
|
||||||
|
--reading-font-size: 1rem;
|
||||||
|
--reading-font-family: inherit;
|
||||||
|
--reading-text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Dark mode — "Ancient Manuscript" ── */
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.197 0.013 55.5);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.899 0.03 80.7);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.246 0.021 86.3);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.899 0.03 80.7);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.246 0.021 86.3);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.899 0.03 80.7);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.753 0.127 84.9);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.251 0.032 69.4);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.288 0.021 82.6);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.899 0.03 80.7);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.288 0.021 82.6);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.646 0.047 78.2);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.288 0.021 82.6);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.899 0.03 80.7);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(0.777 0.059 84.4 / 15%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(0.777 0.059 84.4 / 18%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.636 0.08 86.9);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.753 0.127 84.9);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.646 0.047 78.2);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.636 0.08 86.9);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.45 0.04 75);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.288 0.021 82.6);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.246 0.021 86.3);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.899 0.03 80.7);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.753 0.127 84.9);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.899 0.03 80.7);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.288 0.021 82.6);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.899 0.03 80.7);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.777 0.059 84.4 / 15%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.636 0.08 86.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Reading settings — classes applied to <html> ── */
|
||||||
|
|
||||||
|
/* Serif font — only main content + sidebar */
|
||||||
|
.reading-serif main,
|
||||||
|
.reading-serif aside {
|
||||||
|
font-family: Georgia, "Times New Roman", "Noto Serif", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Font size scaling — uses zoom on main + aside.
|
||||||
|
* zoom is supported in all major browsers including iOS Safari (Apple invented it).
|
||||||
|
* -webkit-text-size-adjust prevents iOS from auto-scaling text in landscape.
|
||||||
|
*/
|
||||||
|
.reading-fs-xs main,
|
||||||
|
.reading-fs-xs aside {
|
||||||
|
zoom: 0.8;
|
||||||
|
-webkit-text-size-adjust: 80%;
|
||||||
|
}
|
||||||
|
.reading-fs-sm main,
|
||||||
|
.reading-fs-sm aside {
|
||||||
|
zoom: 0.9;
|
||||||
|
-webkit-text-size-adjust: 90%;
|
||||||
|
}
|
||||||
|
.reading-fs-base main,
|
||||||
|
.reading-fs-base aside {
|
||||||
|
zoom: 1;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
.reading-fs-lg main,
|
||||||
|
.reading-fs-lg aside {
|
||||||
|
zoom: 1.12;
|
||||||
|
-webkit-text-size-adjust: 112%;
|
||||||
|
}
|
||||||
|
.reading-fs-xl main,
|
||||||
|
.reading-fs-xl aside {
|
||||||
|
zoom: 1.25;
|
||||||
|
-webkit-text-size-adjust: 125%;
|
||||||
|
}
|
||||||
|
.reading-fs-2xl main,
|
||||||
|
.reading-fs-2xl aside {
|
||||||
|
zoom: 1.4;
|
||||||
|
-webkit-text-size-adjust: 140%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for browsers that don't support zoom (Firefox < 126) */
|
||||||
|
@supports not (zoom: 1.1) {
|
||||||
|
.reading-fs-xs main,
|
||||||
|
.reading-fs-xs aside {
|
||||||
|
transform: scale(0.8);
|
||||||
|
transform-origin: top left;
|
||||||
|
width: 125%;
|
||||||
|
}
|
||||||
|
.reading-fs-sm main,
|
||||||
|
.reading-fs-sm aside {
|
||||||
|
transform: scale(0.9);
|
||||||
|
transform-origin: top left;
|
||||||
|
width: 111.11%;
|
||||||
|
}
|
||||||
|
.reading-fs-base main,
|
||||||
|
.reading-fs-base aside {
|
||||||
|
transform: none;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.reading-fs-lg main,
|
||||||
|
.reading-fs-lg aside {
|
||||||
|
transform: scale(1.12);
|
||||||
|
transform-origin: top left;
|
||||||
|
width: 89.28%;
|
||||||
|
}
|
||||||
|
.reading-fs-xl main,
|
||||||
|
.reading-fs-xl aside {
|
||||||
|
transform: scale(1.25);
|
||||||
|
transform-origin: top left;
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
.reading-fs-2xl main,
|
||||||
|
.reading-fs-2xl aside {
|
||||||
|
transform: scale(1.4);
|
||||||
|
transform-origin: top left;
|
||||||
|
width: 71.43%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Justified text — only main content */
|
||||||
|
.reading-justified main {
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user