203 Commits
Author SHA1 Message Date
gosangam 1984797ad3 feat: auth user feedbacks 2026-07-14 18:26:20 +05:30
gosangam 1582df0ed5 chore: refactor settings route 2026-07-14 18:16:32 +05:30
gosangam 1683abc2e9 fix: offline-content renamed to downloads 2026-07-14 16:51:17 +05:30
gosangam 12a5c15121 feat: add daily vani 2026-07-11 00:57:35 +05:30
gosangam 8bc27bb8a1 feat: add daily vani 2026-07-11 00:45:51 +05:30
gosangam 89391279bb feat: add daily vani 2026-07-11 00:31:37 +05:30
gosangam 7f716397a0 feat: kautilya URL update 2026-07-03 13:32:08 +05:30
gosangam 4586f1c22c fix: offline content layout fix 2026-07-02 23:25:40 +05:30
gosangam b6471f9430 fix: offline content for iOS 2026-07-02 21:52:35 +05:30
gosangam 380c23e469 feat: offline content 2026-07-02 21:32:22 +05:30
gosangam 0b67a1142f feat: audio track history 2026-07-02 19:23:22 +05:30
gosangam be28585294 fix: comments drawer fix 2026-07-02 18:57:24 +05:30
gosangam 1462844e95 feat: books sequence update 2026-07-02 17:49:17 +05:30
gosangam 9916810d18 fix: bg reference purport update 2026-06-23 15:13:27 +05:30
gosangam 7b81bbc791 feat: enhance user limits 2026-06-23 14:21:59 +05:30
gosangam f92b41384e feat: add replace_hero hook to custom section 2026-06-21 23:36:25 +05:30
gosangam 5cd75c27f9 fix: user post feed view 2026-06-21 20:00:14 +05:30
gosangam 5679f7a639 fix: user post feed view 2026-06-21 18:35:48 +05:30
gosangam 25db9d0eee feat: add friend request tab in notifications 2026-06-18 22:45:40 +05:30
gosangam 0178f89f2b test: rybbit proxy ip 2026-06-18 01:09:09 +05:30
gosangam b6dc762984 fix: rybbit proxy fix 2026-06-18 00:58:36 +05:30
gosangam 1fe10adb1e fix: rybbit proxy fix 2026-06-18 00:51:09 +05:30
gosangam 84cb273941 fix: feed card follow ui 2026-06-18 00:21:06 +05:30
gosangam 071eb4d58a fix: book transcript browser navigation 2026-06-17 18:00:13 +05:30
gosangam bc1798b31b fix: ui fixes in transcript listing for books 2026-06-17 14:14:45 +05:30
gosangam fc2aef0c3d fix: ui fixes in transcript lusting for books 2026-06-17 14:02:06 +05:30
gosangam 89f94c1a6e fix: ui fixes in transcript nav 2026-06-17 13:48:59 +05:30
gosangam f5abe33c09 feat: add book transcripts categorisation 2026-06-17 12:40:40 +05:30
gosangam f96e7dd2f1 feat: admin notifications 2026-06-16 14:48:29 +05:30
gosangam 0cf6df5942 fix: ui fix 2026-06-16 01:30:41 +05:30
gosangam 5c72416ae8 fix: hidden post 2026-06-16 00:05:07 +05:30
gosangam 197e13c0ef feat: add edit profile page 2026-06-15 23:54:34 +05:30
gosangam 0608e2b6f6 fix: ui fix 2026-06-15 17:15:38 +05:30
gosangam cb376f8497 fix: ui fix 2026-06-15 15:24:49 +05:30
gosangam eac56b7974 fix(seo): fix sitemap URL 2026-06-15 14:04:00 +05:30
gosangam be4c2f17fe feat(seo): add sitemap generator 2026-06-15 13:52:12 +05:30
gosangam 6cc9285bad fix: ui fixes 2026-06-15 12:17:39 +05:30
gosangam ab35767374 fix: ui fixes 2026-06-15 10:13:00 +05:30
gosangam c551d2f7a8 fix: ui fixes 2026-06-15 10:04:11 +05:30
gosangam a4fd63695a fix: ui fixes 2026-06-15 09:53:01 +05:30
gosangam 91621693a6 feat: hide post 2026-06-14 20:11:04 +05:30
gosangam 79f0bd9223 feat: hide post 2026-06-14 19:52:16 +05:30
gosangam 7585e6cb31 fix: ui fixes 2026-06-14 18:04:11 +05:30
gosangam c8de98c8de fix: ui fixes 2026-06-14 16:11:56 +05:30
gosangam 72a092b3c9 fix: ui fixes 2026-06-14 16:06:00 +05:30
gosangam 752ad20fed fix: ui fixes 2026-06-14 15:56:58 +05:30
gosangam 0280a81777 feat: add sb front matter 2026-06-14 15:32:33 +05:30
gosangam 69e5f2c45c feat: add sb front matter 2026-06-14 14:49:25 +05:30
gosangam 45c7cf047e feat: add cc front matter 2026-06-14 13:57:38 +05:30
gosangam a4baf6d215 feat: add bg front matter 2026-06-14 13:28:29 +05:30
gosangam 455ed0f7cb fix: copy behaviour 2026-06-14 12:51:15 +05:30
gosangam 7c3cf656b5 feat: add search in page for books, magazines and other docs 2026-06-14 02:59:42 +05:30
gosangam 23d06a3db5 fix: og image fix for collections 2026-06-14 01:49:44 +05:30
gosangam 3accc753d0 feat: share collection URL 2026-06-14 01:29:08 +05:30
gosangam b04ed1d3aa fix: fix feed component size 2026-06-14 00:43:33 +05:30
gosangam 9808dd3033 fix: fix feed component 2026-06-14 00:35:45 +05:30
gosangam b248f962a1 fix: fix feed component 2026-06-14 00:30:37 +05:30
gosangam 61581b67a7 feat: add feed 2026-06-14 00:18:31 +05:30
gosangam eb0c981d3c feat: add inline mentions in comments 2026-06-13 13:10:34 +05:30
gosangam 37305201bd fix: delete comment modal 2026-06-13 03:15:51 +05:30
gosangam 4471ef10aa fix: delete comment modal 2026-06-13 03:04:46 +05:30
gosangam 397a09e814 fix: ui fixes in emoji picker 2026-06-13 02:53:25 +05:30
gosangam 9e68184750 fix: ui fixes 2026-06-13 02:42:07 +05:30
gosangam 439664a97d chore: code refactor 2026-06-13 02:24:42 +05:30
gosangam cc5633c37a feat: add comments 2026-06-13 01:30:18 +05:30
gosangam 6a0559e608 fix: hide blocked user content 2026-06-13 00:47:11 +05:30
gosangam 25a729959f chore: theme update 2026-06-11 16:14:53 +05:30
gosangam af5ab9cf6b fix: copy clipboard fix 2026-06-11 11:47:31 +05:30
gosangam 884a984e50 fix: search in transcript page ui fixes 2026-06-11 00:39:39 +05:30
gosangam ead8ad02e8 fix: ui fixes 2026-06-11 00:32:04 +05:30
gosangam 4b159cb4d9 fix: drawer fix 2026-06-10 20:02:23 +05:30
gosangam 3903423d06 fix: mention input zoom behaviour 2026-06-10 19:21:32 +05:30
gosangam a46e29d998 fix: mention input zoom behaviour 2026-06-10 16:56:20 +05:30
gosangam 922d76e21f fix: mention user position in post compose drawer 2026-06-10 16:52:21 +05:30
gosangam 909ae67320 feat: mention 2026-06-10 16:29:44 +05:30
gosangam 482a845f37 feat: add email notif for post like 2026-06-09 16:57:37 +05:30
gosangam 647a803b96 feat: add post like 2026-06-09 16:34:10 +05:30
gosangam e36afdd1a5 feat: add followers/following list 2026-06-09 13:20:44 +05:30
gosangam ef6e280524 fix: sync sadhna calendar with local timezone 2026-06-09 00:39:12 +05:30
gosangam cf733062f8 feat: copy caption in post actions menu 2026-06-08 23:45:01 +05:30
gosangam 421b41cade fix: push notification 2026-06-08 17:52:27 +05:30
gosangam deeda40c9d fix: push notification 2026-06-08 17:17:50 +05:30
gosangam 90df5bc784 fix: webpush for iOS 2026-06-08 16:09:09 +05:30
gosangam 0334e03c3c feat: add webpush and sadhna tracker 2026-06-08 15:48:12 +05:30
gosangam ecb32d2e85 wip: sadhna streak 2026-06-07 22:32:34 +05:30
gosangam d5fb350652 fix: rybbit blocking using next rewrite 2026-06-07 00:52:49 +05:30
gosangam bcffe9684f feat: add websocket and analytics 2026-06-07 00:23:54 +05:30
gosangam 0db339254d fix: text selection 2026-06-05 14:13:13 +05:30
gosangam 684ea2ae97 feat: social graph features 2026-06-04 19:30:50 +05:30
gosangam 0f433c5708 fix: audio player 2026-06-03 23:01:33 +05:30
gosangam 48dd450a15 fix: post reshare and audio player enhancements 2026-06-03 14:41:34 +05:30
gosangam ddc6d3c7bf feat: avatar opener 2026-06-03 00:49:58 +05:30
gosangam fd333159f6 fix: font size revert 2026-06-02 23:51:42 +05:30
gosangam 0bcda5a791 fix: ui fixes minor 2026-06-02 23:14:34 +05:30
gosangam c89cc8b779 fix: tailwind fix 2026-06-02 22:57:22 +05:30
gosangam ec67ddf852 feat: social auth account linking 2026-06-02 22:55:09 +05:30
gosangam 086f1dd8aa fix: type fix 2026-06-02 19:38:23 +05:30
gosangam b61c3df473 feat: add google auth 2026-06-02 18:44:43 +05:30
gosangam cb70a84dd5 feat: add password reset email 2026-06-02 18:37:38 +05:30
gosangam dc01d09f09 login with facebook added 2026-06-02 15:21:47 +05:30
gosangam 84d86c45b5 post cards overlay enhancements 2026-06-02 15:13:02 +05:30
gosangam 4d45e20cb1 post cards overlay enhancements 2026-06-02 15:02:17 +05:30
gosangam e72679d1b6 oauth hidden 2026-06-02 12:20:50 +05:30
gosangam 91d5d272d2 feat: add policy pages 2026-06-02 01:57:08 +05:30
gosangam 0352cb2003 search target fix 2026-06-02 00:42:26 +05:30
gosangam 8a8ce24479 fix: reshared posts should be shown in feed 2026-05-31 14:54:48 +05:30
gosangam 7d76afe102 collection drawer ui fix 2026-05-31 11:41:18 +05:30
gosangam 8c1f3a1b7b ui fixes in bottom nav 2026-05-31 11:23:55 +05:30
gosangam 3624c19d2d search page ui fixes 2026-05-31 11:19:35 +05:30
gosangam 5988d02373 search page ui fixes 2026-05-31 11:16:50 +05:30
gosangam 17fb83087f search page ui fixes 2026-05-31 11:09:43 +05:30
gosangam 489adb0eeb feat: send icon fix 2026-05-31 10:53:21 +05:30
gosangam d8de0a18e5 feat: add user search 2026-05-31 10:43:30 +05:30
gosangam c20d6bffe8 feat: share sheet update 2026-05-31 10:25:39 +05:30
gosangam 01bcc057fa feat: share sheet update 2026-05-31 10:24:32 +05:30
gosangam 8bd82a0e8a feat: image compress before upload 2026-05-31 09:56:53 +05:30
gosangam 5506afa758 feat: share post to other apps 2026-05-31 08:44:29 +05:30
gosangam 88f1b08675 feat: share post to other apps 2026-05-31 08:20:06 +05:30
gosangam 336f478f8b feat: share post to other apps 2026-05-31 08:04:46 +05:30
gosangam 03b8f85e06 feat: add reading settings sync 2026-05-31 07:07:50 +05:30
gosangam 0ff8005df2 ui fixes 2026-05-31 06:50:05 +05:30
gosangam 0ae3334d98 type fixes 2026-05-31 00:35:00 +05:30
gosangam ec20eb996b ui enhancements 2026-05-31 00:26:05 +05:30
gosangam dcdd2383c6 feat: add user centric features 2026-05-30 23:44:08 +05:30
gosangam 7bd2952caa tsc fix 2026-05-30 02:46:41 +05:30
gosangam 6b6ce0813e feat: add auth 2026-05-30 02:34:22 +05:30
gosangam 3767a3a9b6 double quotes inclusion 2026-05-30 00:53:50 +05:30
gosangam a22fcf360d search highlight quote fix 2026-05-24 18:07:52 +05:30
gosangam 804cf1ad9a fix: about content 2026-05-17 17:03:52 +05:30
gosangam 30d08c665d feat: add proxy to url shortner 2026-05-11 17:28:04 +05:30
gosangam cc94296b46 fix: copy behaviour 2026-05-11 16:58:15 +05:30
gosangam 8cd1d2e724 feat: add url shortner 2026-05-11 16:14:08 +05:30
gosangam 179b7d8862 chore: update project context 2026-05-11 14:53:03 +05:30
gosangam 7294d9e15b og fix 2026-05-11 12:47:39 +05:30
gosangam e3df40559b fix: search bar now takes full width 2026-05-11 10:22:10 +05:30
gosangam 9a3056fbd8 theme update 2026-05-11 01:58:02 +05:30
gosangam db8deb232b header 2026-05-11 01:36:49 +05:30
gosangam 730efb1bb3 feat: add search result highlight 2026-05-11 01:01:46 +05:30
gosangam 0106d8cee2 feat: fix string in search results 2026-05-11 00:15:48 +05:30
gosangam d917b3ea7d enhance search input 2026-05-10 22:17:38 +05:30
gosangam 7c07b33e4b enhance search input 2026-05-10 22:10:12 +05:30
gosangam 96a83fe1cb fix: search component and breadcrumbs fix 2026-05-10 21:56:43 +05:30
gosangam befcc44980 fix: hide logo on search active 2026-05-10 21:12:13 +05:30
gosangam e45d5eb02c chore: update project context 2026-05-10 20:48:32 +05:30
gosangam 09fbcf93ed feat: add search 2026-05-10 20:41:58 +05:30
gosangam 4cbb146bc1 project context update 2026-05-10 01:12:10 +05:30
gosangam bf1ff47d3e text select more options update 2026-05-10 01:02:15 +05:30
gosangam 9c72ce814d fix: audio minimize issue fixed 2026-05-10 00:56:22 +05:30
gosangam 32da38b24f feat: implement bff layer 2026-05-10 00:35:14 +05:30
gosangam 5951eb5e18 chore: update project context 2026-05-09 14:59:32 +05:30
gosangam ab1517f32f fix: transcripts source and og image 2026-05-09 12:38:35 +05:30
gosangam 326ba090ca feat: add framer motion for audio player, handle og images for transcripts 2026-05-09 11:59:48 +05:30
gosangam 9eb2a06b50 feat: add transcripts with audio links 2026-05-09 10:25:14 +05:30
gosangam 52924096d1 project context updated 2026-05-09 00:21:52 +05:30
gosangam 037072618b feat: add metadata generator for remaining books 2026-05-07 19:40:55 +05:30
gosangam 5cb5827f1c feat: add metadata generator for remaining books 2026-05-07 19:34:04 +05:30
gosangam d7cd1cb4bd fix: text highlight logic 2026-05-07 19:13:56 +05:30
gosangam 6e9fa4ee25 fix: btg link highlight text 2026-05-07 14:48:01 +05:30
gosangam cbd9c55777 fix: og image for glossary 2026-05-06 15:52:44 +05:30
gosangam e5fb370997 fix: update glossary UI 2026-05-06 15:39:22 +05:30
gosangam 0498e3c0ac chore: update project context 2026-05-06 13:23:08 +05:30
gosangam c8f6a96169 chore: repo cleanup 2026-05-06 13:13:57 +05:30
gosangam a9b5417120 chore: remove unused NotoSans font 2026-05-06 12:59:03 +05:30
gosangam e19a4d912d fix: og edge cases with IAST characters handled 2026-05-06 12:54:44 +05:30
gosangam 9730ab0170 feat: add transcriptions collection 2026-05-06 12:21:07 +05:30
gosangam e6529ef62e fix: handle text selection edge cases using new delimeter 2026-05-06 11:56:43 +05:30
gosangam 99eb17da4b chore: add scripts to gitignore 2026-05-06 11:48:16 +05:30
gosangam 9fba172b31 chore: .venv added to gitignore 2026-05-06 11:40:48 +05:30
gosangam 7361f9c14a fix: og image generation 2026-05-06 01:45:47 +05:30
gosangam de374eab8b fix: og image generation 2026-05-06 01:35:06 +05:30
gosangam b81b18632d fix: og image generation 2026-05-06 01:19:06 +05:30
gosangam 0a82a12580 text highlight and og image 2026-05-06 00:40:24 +05:30
gosangam 237840148f fix: letter source 2026-05-05 10:11:30 +05:30
gosangam ec5093e5cb fix: floating text selection bar size 2026-05-05 02:34:56 +05:30
gosangam 391c57070a fix: drawer z-index 2026-05-05 02:28:53 +05:30
gosangam 1bd046d6f3 fix: tooltip size 2026-05-05 02:25:03 +05:30
gosangam 1930f5e034 test: floating text selection toolbar 2026-05-05 02:20:53 +05:30
gosangam eb2cba3af1 fix: ts fix 2026-05-05 01:48:37 +05:30
gosangam 49a60eb646 feat: text selection tooltip 2026-05-05 01:42:02 +05:30
gosangam ea1914b283 feat: refine letters UI 2026-05-05 01:08:19 +05:30
gosangam 545a7e2020 feat: about page added, password gate removed 2026-05-04 13:00:39 +05:30
gosangam 89f42b6eb3 feat: add disciples list 2026-05-04 11:39:57 +05:30
gosangam 45c080c571 btg: paragraphJson fix 2026-05-04 00:40:14 +05:30
gosangam 004ff6ca2f btg: paragraphJson fix 2026-05-04 00:30:13 +05:30
gosangam e55ab47b66 fix: letter filter labels updated 2026-05-04 00:17:19 +05:30
gosangam 7825293390 feat: update project context 2026-05-04 00:12:10 +05:30
gosangam 401766928a feat: letters added 2026-05-04 00:08:31 +05:30
gosangam 35d1d14f37 feat: add journals 2026-05-03 21:02:41 +05:30
gosangam 89106efc98 fix: sva now extracts and reads properly 2026-05-03 14:46:35 +05:30
gosangam 47a2dc375e fix: SVA chapters 2026-05-02 17:45:32 +05:30
gosangam ae6a752787 feat: sva extracted and seeded 2026-04-30 19:45:17 +05:30
gosangam 1f2ac2198a fix: font size slider in mobile 2026-04-18 13:43:28 +05:30
gosangam 67b9ab6b16 fix: font size slider in mobile 2026-04-18 13:35:42 +05:30
gosangam 5a2e556487 styling fixes 2026-04-18 12:47:08 +05:30
gosangam c865b59088 feat: add accessibility features to UI 2026-04-18 12:25:36 +05:30
gosangam 5235f479b3 fix: bg chapter verse index 2026-04-18 01:39:01 +05:30
gosangam c573c94c51 feat: add password gate 2026-04-18 00:14:32 +05:30
gosangam 9b5823146f feat: add mobile sidebar 2026-04-17 23:20:59 +05:30
gosangam 0aab1d23ec feat: add ssr 2026-04-17 22:51:58 +05:30
gosangam d88ababd76 feat: kb both volumes 2026-04-16 00:48:48 +05:30
gosangam abb7cc49d0 feat: allow path params for verse navigation 2026-04-15 13:41:34 +05:30
gosangam c0a00d4c04 feat: add pagination 2026-04-15 00:35:16 +05:30
gosangam 213eea8e94 feat: add pagination 2026-04-14 23:15:26 +05:30
20755 changed files with 65935 additions and 4946460 deletions
+20
View File
@@ -0,0 +1,20 @@
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org
# BFF API Server (server-side fetches)
BFF_URL=http://localhost:8080
# BFF API Server (browser-side fetches; must be publicly reachable in prod)
NEXT_PUBLIC_BFF_URL=http://localhost:8080
# RTF Viewer (development only)
ALLOW_RTF=true
NEXT_PUBLIC_OAUTH_ENABLED=true
# SOKETI
NEXT_PUBLIC_SOKETI_KEY=spsays
NEXT_PUBLIC_SOKETI_HOST=ws.prabhupadasays.org
NEXT_PUBLIC_SOKETI_PORT=443
NEXT_PUBLIC_SOKETI_FORCE_TLS=true
# Analytics
NEXT_PUBLIC_RYBBIT_HOST=https://analytics.krispire.cloud
+4 -1
View File
@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env
# vercel # vercel
.vercel .vercel
@@ -42,3 +42,6 @@ next-env.d.ts
public/rtfs public/rtfs
/lib/generated/prisma /lib/generated/prisma
/lib/jsons
.venv
scripts
+865
View File
@@ -0,0 +1,865 @@
# 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
- **Go BFF API** — all data fetched via `lib/bff.ts` HTTP client calling the Go BFF server (no direct database access)
- **framer-motion** for audio player animations (spring enter/exit, minimize/expand transitions, audio wave bars)
- **Typesense** (via BFF) — typo-tolerant full-text search across all content
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
---
## 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
│ ├── search/ → Search page (server + client components)
│ │ ├── page.tsx → Server component (URL-driven state)
│ │ └── search-content.tsx → Client component (input, filters, results)
│ └── api/
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
│ ├── books/ → Book/verse API routes
│ ├── search/route.ts → BFF search proxy (Typesense via /api/v1/search)
│ └── 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
│ ├── header-search.tsx → Header search button with framer-motion expand animation
│ ├── 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/
│ ├── bff.ts → BFF API client (typed fetch wrapper: bff<T>(), bffList<T>(), bffOrThrow<T>())
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary) — calls BFF API
│ ├── books.ts → Generic book data layer (replaces 29 individual book files)
│ ├── 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 — calls BFF API
│ ├── letters.ts → Letters data access (by year, recipient, location) — calls BFF API
│ ├── transcriptions.ts → Transcriptions data access — calls BFF API
│ ├── utils.ts → cn() helper
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.) — calls BFF API
│ ├── other-documents.ts → Other Documents data access — calls BFF API
│ ├── journals.ts → Journals data access — calls BFF API
│ └── magazines.ts → Magazines data access — calls BFF API
└── 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
```
---
## Data Architecture
The knowledgebase **never connects to MongoDB directly**. All data is served by the **Go BFF API** server.
### BFF Server
- **URL**: Configured via `BFF_URL` environment variable (default: `http://localhost:8080`)
- The BFF reads from MongoDB — the knowledgebase frontend has no database driver or connection string
- All API endpoints live under `/api/v1/...` on the BFF server
### BFF Client (`lib/bff.ts`)
The typed fetch wrapper provides three helpers:
- **`bff<T>(path, options?)`** — Fetch JSON from the BFF. Returns `null` on 404, throws `BffError` on other failures.
- **`bffList<T>(path, options?)`** — Fetch a list. Returns `[]` on 404 (instead of null).
- **`bffOrThrow<T>(path, options?)`** — Fetch JSON, throwing on 404. Use when a missing resource should be an error.
Options include `params` (query parameters), `revalidate` (Next.js ISR seconds, default 60), and `tags` (cache tags).
### Response Envelope
BFF responses use a `{ success: true, data: ... }` envelope. The `bff()` client auto-unwraps this — callers receive the inner `data` value directly.
### Case Mapping
The BFF uses **snake_case** JSON field names (Go convention). Each frontend data layer file (`lib/letters.ts`, `lib/transcriptions.ts`, `lib/books.ts`, etc.) maps BFF responses to **camelCase** TypeScript types used by React components.
### Data Layer Files
Individual `lib/*.ts` files are thin wrappers that:
1. Call a BFF endpoint via `bff()` / `bffList()` / `bffOrThrow()`
2. Map the snake_case response to camelCase frontend types
3. Export typed functions consumed by Next.js server components
Key data layers and their BFF endpoint prefixes:
- `lib/api.ts` → `/api/v1/books/*`, `/api/v1/glossary/*` — Books, verses, prose chapters, glossary
- `lib/books.ts` → `/api/v1/books/{code}/*` — Generic book data layer (replaces 29 individual per-book files)
- `lib/letters.ts` → `/api/v1/letters/*` — Letters (by year, recipient, location)
- `lib/transcriptions.ts` → `/api/v1/transcriptions/*` — Transcriptions (by year, place, type)
- `lib/disciples.ts` → `/api/v1/disciples/*` — Disciples directory
- `lib/other-documents.ts` → `/api/v1/other-documents/*` — Other Documents
- `lib/journals.ts` → `/api/v1/journals/*` — Journals
- `lib/magazines.ts` → `/api/v1/magazines/*` — Magazines
---
## 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 filters via the BFF endpoint (matches 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` (calls BFF API, NOT local JSON files or direct MongoDB)
### 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
- **Palette**: 3 light + 3 dark colour palettes, each selectable from a swatch grid in the settings panel
- Light: **Aged Parchment** (warm cream/saffron — default), **Tulasī Garden** (sage green/emerald), **Yamunā Mist** (sky-blue/cerulean)
- Dark: **Ancient Manuscript** (warm brown/saffron — default), **Nīlamaṇi** (deep navy/sky-blue — Kṛṣṇa's "blue jewel"), **Vraja Twilight** (deep wine/rose-red)
- Swatches in the settings panel show only the palettes for the currently resolved theme (uses `resolvedTheme` from next-themes, so System mode resolves correctly)
- Each palette defines a full set of CSS variables (background, foreground, primary, card, muted, accent, border, etc.) so the entire UI adapts cohesively
- Palette CSS lives in `app/globals.css` under `:root:not(.dark)[data-light-palette="..."]` and `.dark[data-dark-palette="..."]` selectors. The `:not(.dark)` scoping prevents light-palette attribute rules from overriding `.dark` (an attribute selector has higher specificity than a plain class selector)
- **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` (now includes `lightPalette` and `darkPalette` fields)
- Applied via CSS classes (`reading-serif`, `reading-fs-*`, `reading-justified`) AND data attributes (`data-light-palette`, `data-dark-palette`) 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
### Page Header (`components/page-header.tsx`)
- Shared client component used on every page — wraps the title/breadcrumb area and the right-side actions (search button + reading settings)
- **Mobile layout** uses CSS `float-right` on the actions block so the title text genuinely wraps around them on the first line and uses the **full container width** on every subsequent line. (CSS `padding-right` was previously used but applies the same gap to every line, leaving an empty column down the right edge.)
- When the header search is expanded on mobile, the actions block drops the float, the logo/title area is hidden via `max-md:hidden`, and the search bar fills the full header width edge-to-edge (no `ml-2` margin in the expanded state)
- **Desktop layout** is unchanged — standard `md:flex md:items-start md:justify-between md:gap-4` with the actions block on the right (`md:order-1 md:shrink-0`)
- Reads a sessionStorage flag (`spsays_from_search`) on first render via a lazy `useState` initialiser; if set, renders a “← Back to search results” button that calls `router.back()` and **hides the page's own first-child `<Link>` breadcrumb** via `[&>a:first-child]:hidden` so there's never a double-arrow stack
- The lazy initialiser also clears the flag on read (one-shot), so navigating from the result page to a subsequent page (e.g. next verse) doesn't show the search return button again
### Search
**Search** (`/search` page) — Typo-tolerant, diacritic-insensitive full-text search across all content.
- **Powered by Typesense** via the BFF (`/api/v1/search` proxied through `/api/search`)
- **9 indexed content types** combined into 8 user-facing filters: Books (verses + prose), Letters, Transcriptions, Glossary, Essays, Journals, Magazines, Other Documents
- **Books filter** combines verses and prose into one tab (sends `type=verses,prose` to the BFF)
- **Multi-select filters** — clicking multiple type tabs combines them (e.g., Books + Letters); clicking "All" clears all filters
- **URL state** — `q`, `type`, and `page` are synced to the URL (e.g. `/search?q=foo&type=books,letters&page=2`), so any search is shareable and bookmarkable
- **Pagination** — appears both above the results (compact: prev/next + page count) and below them
- **Result cards** — the entire card is a clickable `Link` that navigates to the source page
- **Header search** — `HeaderSearchButton` on every page header expands into a search input on click; the `/` keyboard shortcut opens the search input from any page
- **300ms debounce** on input
- **Highlighted snippets** — matched text wrapped in `<mark>` tags
- **Typo tolerance** — "Krishna", "Krsna", "Krishn" all find Kṛṣṇa results
- **Diacritic insensitivity** — `_ascii` shadow fields indexed alongside originals (e.g., "Yashodanandana" matches "Yaśodanandana")
- **Prefix matching** — partial words like "sarv dharman parit" match `sarva-dharmān parityajya`
- **Hyphenated terms** — every collection (not just verses) has `token_separators: ["-"]`, so `Dīkṣā-guru` indexes as two tokens (`dīkṣā` + `guru`) and a query for "diksa guru" matches it cleanly
- **Stopwords** — a server-side Typesense stopwords set (`common`) strips conversational tokens like `letter`, `to`, `of`, `essay`, `magazine`, etc. from queries so they don't drown out specific intent. e.g. "letter to tusta krsna" effectively searches for "tusta krsna" and surfaces actual letters to that recipient instead of any letter whose body contains "letter" + "to"
- **Exact > prefix > typo ranking** — `prioritize_exact_match=true` + `prioritize_token_position=true` so "karma" outranks "karmic", and exact recipient matches outrank typo matches
- **Glossary results** — glossary pages read `?term=` from the URL so a search result lands on the correct term (with diacritic-insensitive matching)
- **Defensive null handling** — the frontend always treats a null `results` payload as an empty array
- Files: `app/search/page.tsx`, `app/search/search-content.tsx`, `app/api/search/route.ts`, `components/header-search.tsx`
**Header search behaviour.** When the user submits a search from the inline header input, `HeaderSearchButton` automatically appends a `type` query param matching the section they're currently in (via `usePathname()`):
| Current path | Search URL gets | Active tab |
|---|---|---|
| `/books/**` | `type=books` | Books |
| `/letters/**` | `type=letters` | Letters |
| `/transcriptions/**` | `type=transcriptions` | Transcriptions |
| `/essays/**` | `type=essays` | Essays |
| `/glossary/**` | `type=glossary` | Glossary |
| `/journals/**` | `type=journals` | Journals |
| `/magazines/**` | `type=magazines` | Magazines |
| `/other-documents/**` | `type=other_documents` | Other Documents |
| `/`, `/search`, `/about` | no `type` param | All |
**Header search expand UX.** The collapsed icon expands into a 400px-wide bar on desktop (capped via `md:max-w-100`, animated to `width: 100%` of its container by framer-motion) and a full-width bar on mobile. The bar has a “Close” X on the left, the text input in the middle, and a primary-coloured “Search ⏎” submit button on the right — the `CornerDownLeft` icon hints that Enter also submits. Pressing Esc collapses. Spring animation on enter, instant collapse (no exit animation, no AnimatePresence — the previous use of `AnimatePresence mode="popLayout"` caused a backwards-shrink flash because `popLayout` pulled the exiting element out of flow and ran a layout-level FLIP).
**Back to search results.** When a user clicks a result card, `ResultCard`'s `onClick` writes a one-shot `spsays_from_search` flag to sessionStorage. On the destination page, `PageHeader` reads (and consumes) this flag in a lazy `useState` initialiser; if present, it replaces the page's own breadcrumb link with a “← Back to search results” button that calls `router.back()`. Client-side navigation preserves `AudioPlayerProvider` so audio playback continues uninterrupted.
**Search-result highlight.** Each result card builds its destination URL through `buildSearchResultUrl(result.url, result.snippet)` from `lib/text-highlight.ts`. This extracts the text inside the snippet's `<mark>` tags (from first mark to last mark, HTML stripped), encodes it via the existing `encodeTextFragment()` helper, and appends it as `?hl=...`. The destination page's existing `TextHighlighter` then finds and highlights the matched phrase — reusing the exact same `?hl=` infrastructure as share links, with no changes to `TextHighlighter` itself.
**Search relevance.** Search is tuned to find verses where the verse text itself contains the query, not commentaries that cite it. Verse purports are intentionally excluded from the default search so results stay focused on primary sources. In practice this means searching for `sarva dharman parityajya` returns BG 18.66 first (the actual verse) rather than the many commentaries that quote it.
### Per-Page Filters
- 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 Palettes
Three themes per mode, all named after concepts from the Vedic tradition. Each defines a complete set of CSS variables (background, foreground, primary, card, muted, accent, border, ring, sidebar variants, charts) for cohesive styling site-wide.
**Light palettes**
- **Aged Parchment** (default) — warm cream backgrounds, deep sepia text, saffron/ochre primary
- **Tulasī Garden** — sage-green backgrounds, deep forest text, emerald primary; inspired by the sacred Tulasī plant
- **Yamunā Mist** — soft blue backgrounds, deep teal text, cerulean primary; inspired by the sacred Yamunā river
**Dark palettes**
- **Ancient Manuscript** (default) — deep warm brown backgrounds, parchment text, golden saffron primary
- **Nīlamaṇi** — deep navy backgrounds, soft moonlight text, sky-blue primary; “blue jewel,” one of Kṛṣṇa's epithets
- **Vraja Twilight** — deep wine backgrounds, warm cream text, rose-red primary; the crimson dusk over Vraja
Palettes are switched independently of theme: the user can pick their preferred light palette AND their preferred dark palette — the right one takes effect automatically when next-themes toggles between modes. Selection happens in `components/reading-settings.tsx` via a 3-up grid of swatch buttons (each swatch is a paint-chip-style square with the palette's background colour plus a small primary-coloured dot in the corner).
### 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)
- **HTML-entity regex must be strict.** `extractMatchedText()` strips HTML entities from paragraph strings before searching. The regex matches only real entity shapes — named (`&amp;`), decimal (`&#39;`), and hex (`&#x27;`) — not the loose `&[^;]+;` pattern, which would pair a stray `&` (e.g. "24 & 25 Oct") with a far-away `;` (e.g. "my beloved son;") and eat everything between them, breaking OG quote extraction for any selection that spans such a stray ampersand. The browser-side `findAndHighlightText` is unaffected because it walks DOM text nodes that the browser has already decoded.
- `buildSearchResultUrl(resultUrl, snippetHtml)` extracts the matched text from a search snippet's `<mark>` tags and appends `?hl=...` to the result URL so the destination page highlights the match — same infrastructure as share links.
### 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**: Full player shows two rows: track info row + centered transport controls
- **Auto-minimize**: After 8 seconds of inactivity on **both mobile and desktop**, the player auto-minimizes to a floating pill. The minimized pill shows cover image with a circular progress ring and audio wave animation. Tap/click to expand.
- **X button behavior**: Minimizes the player if audio is playing; stops playback entirely if audio is paused
- **Clicking outside the player**: Minimizes it (does not stop playback)
- **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
```
BFF_URL=http://localhost:8080 # Go BFF API server URL (default: http://localhost:8080)
ALLOW_RTF=true # Enable RTF viewer (development only)
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org # Public site URL for OG images
```
> No new vars are needed for the frontend search feature — search goes through the BFF, which has `TYPESENSE_URL` and `TYPESENSE_API_KEY` configured server-side.
---
## How to Run
```bash
# 1. Start the Go BFF server (required — all data comes from the BFF)
cd spsays-bff && go run cmd/api/main.go # Starts on :8080 by default
# 2. Development (from knowledgebase/)
npm run dev # Next.js dev server with Turbopack
# 3. Build
npx next build
# Extraction & seeding scripts are in extractor/ — see extractor/scripts/
```
> **Note**: No `npx prisma generate`, `npx prisma db push`, or MongoDB connection string needed in the frontend. The BFF server must be running for any page to load data.
---
## 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. **BFF dependency** — The knowledgebase requires the Go BFF server to be running; all pages will fail to load data if the BFF is down.
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. **Transcription BSON fallback** — 1 document stores content as `contentJson` string due to BSON wire protocol limits; `getTranscription()` transparently parses it
7. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
8. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
9. **Search depends on Typesense** — Search depends on Typesense being available via the BFF; if Typesense is down, search will return empty results but the rest of the site is unaffected.
10. **Palette FOUC on first load** — Palette data attributes are applied via the `applyClasses` effect after first paint. A user with a non-default palette will see a brief flash of the default palette before their preference kicks in. Could be fixed by injecting an inline script in `layout.tsx` that reads `localStorage` before hydration (next-themes does this for the `.dark` class). Not yet implemented.
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { auth, AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await auth.forgotPassword(email);
setSubmitted(true);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Something went wrong.");
} finally {
setSubmitting(false);
}
}
if (submitted) {
return (
<AuthCard
title="Check your inbox"
description="If an account exists for that email, we've sent a reset link. It will expire in 1 hour."
footer={<AuthFooterLink prefix="Back to" href="/login" label="log in" />}
>
<p className="text-sm text-muted-foreground">
Didn&apos;t get an email? Check your spam folder or try again in a minute.
</p>
</AuthCard>
);
}
return (
<AuthCard
title="Reset your password"
description="Enter your email and we'll send you a reset link."
footer={<AuthFooterLink prefix="Remembered it?" href="/login" label="Log in" />}
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Sending…" : "Send reset link"}
</Button>
</form>
</AuthCard>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
/**
* Shared layout for the auth pages (login, signup, forgot-password,
* reset-password). Provides the centered card frame so each page only
* has to render its own form.
*
* Also gates the routes: a signed-in user landing on any /login, /signup,
* etc. URL is bounced to /u/me (or the `next` query param if present).
* The check happens client-side because auth state lives in
* AuthProvider — there's no server-side cookie inspection here yet, and
* the redirect is purely cosmetic (logged-in users don't gain anything
* from re-seeing these forms, and signing in over an existing session
* would just no-op).
*/
import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/components/auth/auth-provider";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
// useSearchParams forces a CSR bailout during prerender, so Next.js
// requires a Suspense boundary anywhere it's read. The gate is the
// only consumer here — isolate it so the rest of the layout can still
// SSR normally.
return (
<main className="flex items-center justify-center px-4 py-12 min-h-[90vh]">
<Suspense fallback={<AuthShell>{children}</AuthShell>}>
<AuthGate>{children}</AuthGate>
</Suspense>
</main>
);
}
function AuthShell({ children }: { children: React.ReactNode }) {
return <div className="w-full max-w-md">{children}</div>;
}
function AuthGate({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
const params = useSearchParams();
useEffect(() => {
if (loading || !user) return;
const next = params.get("next");
router.replace(next && next.startsWith("/") ? next : "/u/me");
}, [loading, user, router, params]);
// Don't flash the form for an instant before the redirect fires.
if (!loading && user) {
return <p className="text-sm text-muted-foreground">Redirecting…</p>;
}
return <AuthShell>{children}</AuthShell>;
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { LoginForm } from "@/components/auth/login-form";
function LoginContent() {
const router = useRouter();
const params = useSearchParams();
const next = params.get("next") ?? "/u/me";
const oauthError = params.get("oauth_error");
return (
<AuthCard
title="Welcome back"
description="Log in to access your bookmarks, notes, and collections."
footer={
<AuthFooterLink prefix="No account?" href="/signup" label="Create one" />
}
>
{oauthError ? (
<p className="text-sm text-destructive">
{oauthErrorMessage(oauthError)}
</p>
) : null}
<LoginForm
oauthNext={next}
onSuccess={() => {
router.push(next);
router.refresh();
}}
/>
</AuthCard>
);
}
function oauthErrorMessage(code: string): string {
switch (code) {
case "invalid_state":
case "missing_state":
return "Your sign-in link expired. Please try again.";
case "exchange_failed":
case "profile_failed":
return "Sign-in failed while talking to the provider. Please try again.";
case "unknown_provider":
return "That sign-in provider isn't configured. Please use email or another method.";
case "unavailable":
return "Social sign-in is temporarily unavailable. Please use email/password.";
case "server":
return "Something went wrong on our side. Please try again in a moment.";
default:
return "Sign-in failed. Please try again.";
}
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginContent />
</Suspense>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { auth, AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function ResetPasswordForm() {
const params = useSearchParams();
const router = useRouter();
const token = params.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
if (!token) {
return (
<AuthCard title="Invalid reset link" description="The link is missing a token. Please request a new one.">
<AuthFooterLink prefix="Need a new link?" href="/forgot-password" label="Request one" />
</AuthCard>
);
}
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setSubmitting(true);
try {
await auth.resetPassword(token, password);
setDone(true);
setTimeout(() => router.push("/login"), 1500);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Reset failed.");
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<AuthCard
title="Password updated"
description="Redirecting you to the login page…"
>
<AuthFooterLink prefix="Take me there now —" href="/login" label="Log in" />
</AuthCard>
);
}
return (
<AuthCard
title="Set a new password"
description="Choose a strong password you haven't used before."
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
autoComplete="new-password"
minLength={8}
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Updating…" : "Update password"}
</Button>
</form>
</AuthCard>
);
}
export default function ResetPasswordPage() {
// useSearchParams must be inside Suspense for Next.js streaming.
return (
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { SignUpForm } from "@/components/auth/signup-form";
export default function SignUpPage() {
const router = useRouter();
return (
<AuthCard
title="Create your account"
description="Save bookmarks, build collections, and follow Śrīla Prabhupāda's words across devices."
footer={
<AuthFooterLink prefix="Already registered?" href="/login" label="Log in" />
}
>
<SignUpForm
oauthNext="/u/me"
onSuccess={() => {
router.push("/u/me");
router.refresh();
}}
/>
</AuthCard>
);
}
@@ -0,0 +1,306 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/bbd/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Beyond Birth and Death" })} />
<PageHeader sticky>
<Link
href="/books/bbd"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Beyond Birth and Death
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBbdMeta, getBbdIntroSegments } from "@/lib/bbd";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/bbd", name: "Beyond Birth and Death" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/bbd/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bbd/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,133 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/(reader)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/layout/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"bg",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Bhagavad-gītā As It Is",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("bg", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
type BgMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const meta = await getBookMeta("bg");
if (!meta) notFound();
const bgMeta = meta as unknown as BgMeta;
const chapterInfo = bgMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getSingleVerse(
"bg",
null,
parseInt(chapter),
requestedVerse,
);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/bg/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bg"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Bhagavad-gītā
</Link>
<div className="mt-2 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verse}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="bg"
division={null}
chapter={parseInt(chapter)}
basePath={`/books/bg/${chapter}`}
showBengali={false}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${title}`}
/>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import Link from "next/link";
import { getChapterVerseIndex, getProseChapter } 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/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
// Non-numeric segments are front matter (dedication, foreword, …).
if (isNaN(parseInt(chapter))) {
const segment = await getProseChapter("bg", 0, chapter);
return buildOgMeta({
title: BOOK_NAMES["bg"],
detail: segment?.title ?? "Front Matter",
});
}
return buildOgMeta({ title: BOOK_NAMES["bg"], detail: `Chapter ${chapter}` });
}
export default async function ChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Front matter — non-numeric slugs (dedication, foreword, preface,
// introduction, disciplic-succession) resolve to prose intro-segments.
if (isNaN(parseInt(chapter))) {
const segment = await getProseChapter("bg", 0, chapter);
if (!segment) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {BOOK_NAMES["bg"]}
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{segment.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card id="book-content">
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
// Numeric chapters — redirect to the first verse.
const verseIndex = await getChapterVerseIndex("bg", null, parseInt(chapter));
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/bg/${chapter}/${verseIndex[0].verse}`);
}
+115
View File
@@ -0,0 +1,115 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBookMeta, getProseChapters } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
import { Card, CardContent } from "@/components/ui/card";
import { ExtractedVerseCount } from "../extracted-verse-count";
export function generateMetadata(): Metadata {
return ogForBook("bg");
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
export default async function BgPage() {
const [meta, introSegments] = await Promise.all([
getBookMeta("bg"),
getProseChapters("bg", 0, "intro-segment"),
]);
if (!meta) notFound();
const intro = meta as unknown as {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: ChapterInfo[];
};
return (
<div className="min-h-screen bg-background">
<JsonLd data={bookJsonLd({ path: "/books/bg", name: "Bhagavad-gītā As It Is" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{intro.title}
</h1>
<p className="mt-1 text-muted-foreground">{intro.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 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 py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/bg/${seg.slug}`}
className="flex px-5 py-4 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{seg.title}
</p>
</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">
{intro.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,270 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
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">
<JsonLd data={articleJsonLd({ path: `/books/bk/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Bhagavāner Kathā" })} />
<PageHeader sticky>
<Link
href="/books/bk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Bhagavāner Kathā
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+75
View File
@@ -0,0 +1,75 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/bk", name: "Bhagavāner Kathā" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
"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(() => {
const base = query.trim()
? normalizedBooks
.filter((entry) =>
entry.searchText.includes(normalize(query.trim())),
)
.map((entry) => entry.book)
: books;
return [...base].sort((a, b) =>
normalize(a.title).localeCompare(normalize(b.title)),
);
}, [books, normalizedBooks, query]);
return (
<div className="space-y-6">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search books by name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Results count when searching */}
{query.trim() && (
<p className="text-sm text-muted-foreground">
{filtered.length === 0
? "No books found"
: `${filtered.length} book${filtered.length === 1 ? "" : "s"} found`}
</p>
)}
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((book) => (
<Link key={book.slug} href={book.href} className="block h-full">
<Card
className={cn(
"h-full transition-all hover:shadow-md hover:ring-foreground/20",
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold leading-snug">
{book.title}
</CardTitle>
{book.contentDescription && (
<CardDescription className="text-xs line-clamp-2">
{book.contentDescription}
</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{book.description && (
<Badge variant="secondary" className="text-xs">
{book.description}
</Badge>
)}
{book.totalVerses > 0 && (
<ExtractedVerseCount
count={book.totalVerses}
label="verses"
className="text-muted-foreground text-xs"
/>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,219 @@
import type { Metadata } from "next";
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/(reader)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/layout/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
type BsMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("bs");
if (meta) return meta as unknown as BsMeta;
const introPath = path.join(BS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as BsMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("bs", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("bs", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
BS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `bs-${chapter}-${verse}`,
book: "bs",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getVerseWithFallback(parseInt(chapter), parseInt(verse));
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrī Brahma-saṁhitā",
detail: `Chapter ${chapter}, Verse ${parseInt(verse)}`,
quote,
});
}
return ogForVerse("bs", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const bsMeta = await getMetaWithFallback();
if (!bsMeta) notFound();
const chapterInfo = bsMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/bs/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bs"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrī Brahma-saṁhitā
</Link>
<div className="mt-2 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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,121 @@
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/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import Link from "next/link";
import fs from "fs";
import path from "path";
const BS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bs");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({ title: BOOK_NAMES["bs"], detail: `Chapter ${chapter}` });
}
export default async function BsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction, intro-foreword)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(BS_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/bs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Brahma-saṁhitā
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{segment.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card id="book-content">
<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}`);
}
+183
View File
@@ -0,0 +1,183 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/bs", name: "Śrī Brahma-saṁhitā" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{(introduction || foreword) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introduction && (
<li>
<Link
href="/books/bs/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{foreword && (
<li>
<Link
href="/books/bs/intro-foreword"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{foreword.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/bs/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,270 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
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">
<JsonLd data={articleJsonLd({ path: `/books/cbhc/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Śrī Caitanya-bhāgavata Commentary" })} />
<PageHeader sticky>
<Link
href="/books/cbhc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Caitanya-bhāgavata Commentary
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+75
View File
@@ -0,0 +1,75 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/cbhc", name: "Śrī Caitanya-bhāgavata Commentary" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/cbhc/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,143 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/(reader)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/layout/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ lila: string; chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { lila, chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"cc",
lila,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
const bookTitle = "Śrī Caitanya-caritāmṛta";
return buildOgMeta({
title: bookTitle,
detail: `${lila.charAt(0).toUpperCase() + lila.slice(1)}, Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("cc", {
division: lila,
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function CcVersePage({
params,
}: {
params: Promise<{ lila: string; chapter: string; verse: string }>;
}) {
const { lila, chapter, verse } = await params;
const meta = await getBookMeta("cc");
if (!meta) notFound();
const lilas = meta.lilas as LilaInfo[] | undefined;
const lilaInfo = lilas?.find((l) => l.key === lila);
if (!lilaInfo) notFound();
const chapterInfo = lilaInfo.chapters.find(
(ch) => ch.number === parseInt(chapter),
);
if (!chapterInfo) notFound();
const verseIndex = await getChapterVerseIndex("cc", lila, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verse);
const verseData = await getSingleVerse(
"cc",
lila,
parseInt(chapter),
requestedVerse,
);
if (!verseData) notFound();
// Build mobile nav groups from verse index
const mobileNavGroups: MobileNavGroup[] = [];
if (verseIndex.length > 0) {
mobileNavGroups.push({
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/cc/${lila}/${chapter}/${v.verse}`,
type: "verse",
})),
});
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href={`/books/cc/${lila}`}
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; {lilaInfo.name}
</Link>
<div className="mt-2 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
Chapter {chapter}: {chapterInfo.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verseData}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="cc"
division={lila}
chapter={parseInt(chapter)}
showBengali={true}
basePath={`/books/cc/${lila}/${chapter}`}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
/>
</div>
</div>
);
}
@@ -0,0 +1,227 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect, notFound } from "next/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { getBookMeta, getProseChapter, getChapterVerseIndex } from "@/lib/api";
import { CC_LILAS } from "@/lib/constants";
import { buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { FindInPage } from "@/components/layout/find-in-page";
import { MobileNav, type MobileNavGroup } from "@/components/layout/mobile-nav";
import { cn } from "@/lib/utils";
type ChapterInfo = { number: number; title: string; totalVerses: number };
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
type VerseEntry = { verse: number; verseEnd?: number | null; reference: string };
export async function generateMetadata({
params,
}: {
params: Promise<{ lila: string; chapter: string }>;
}): Promise<Metadata> {
const { lila, chapter } = await params;
const lilaName = CC_LILAS[lila] || lila;
return buildOgMeta({
title: "Śrī Caitanya-caritāmṛta",
detail: `${lilaName}, Chapter ${chapter}`,
});
}
function verseLabel(v: VerseEntry): string {
return v.verseEnd ? `${v.verse}–${v.verseEnd}` : String(v.verse);
}
// ---------------------------------------------------------------------------
// Top/bottom navigation — mirrors the verse pages' VerseNav: a Contents
// toggle (mobile) listing this chapter's verses, Previous/Next (chapter), and
// a "Chapter X of N" label.
// ---------------------------------------------------------------------------
function ChapterNav({
lila,
chapters,
current,
chapterTitle,
verseIndex,
}: {
lila: string;
chapters: ChapterInfo[];
current: number;
chapterTitle: string;
verseIndex: VerseEntry[];
}) {
const idx = chapters.findIndex((c) => c.number === current);
const prev = idx > 0 ? chapters[idx - 1] : null;
const next = idx < chapters.length - 1 ? chapters[idx + 1] : null;
// The contents drawer lists the active chapter's verses (same as the
// verse-page sidebar) so the reader can jump straight in.
const groups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: `Verse ${verseLabel(v)}`,
href: `/books/cc/${lila}/${current}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={groups}
currentSlug=""
bookTitle={`Chapter ${current}: ${chapterTitle}`}
/>
{prev ? (
<Link
href={`/books/cc/${lila}/${prev.number}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
<span className="text-sm text-muted-foreground">
Chapter {current} of {chapters.length}
</span>
{next ? (
<Link
href={`/books/cc/${lila}/${next.number}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
export default async function CcChapterPage({
params,
}: {
params: Promise<{ lila: string; chapter: string }>;
}) {
const { lila, chapter } = await params;
const chapterNum = parseInt(chapter);
const [meta, summary, verseIndex] = await Promise.all([
getBookMeta("cc"),
getProseChapter("cc", 0, `${lila}-${chapterNum}`),
getChapterVerseIndex("cc", lila, chapterNum),
]);
if (!meta) notFound();
const lilaInfo = (meta.lilas as LilaInfo[] | undefined)?.find(
(l) => l.key === lila,
);
if (!lilaInfo) notFound();
const chapterInfo = lilaInfo.chapters.find((ch) => ch.number === chapterNum);
if (!chapterInfo) notFound();
if (verseIndex.length === 0) notFound();
const firstVerse = verseIndex[0].verse;
// Chapters without a summary (e.g. Ādi 7) go straight to the verses.
if (!summary || summary.paragraphs.length === 0) {
redirect(`/books/cc/${lila}/${chapterNum}/${firstVerse}`);
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href={`/books/cc/${lila}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {lilaInfo.name}
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
Chapter {chapterNum}: {chapterInfo.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Desktop sidebar — verses of the active chapter (matches the
verse-page sidebar) so the reader can jump to any verse. */}
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-165px)] overflow-y-auto py-8 pr-4">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Verses
</h2>
<nav className="grid grid-cols-3 gap-1">
{verseIndex.map((v) => (
<Link
key={v.verse}
href={`/books/cc/${lila}/${chapterNum}/${v.verse}`}
className="px-2 py-1 rounded-md text-sm tabular-nums text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
{verseLabel(v)}
</Link>
))}
</nav>
</aside>
{/* Main — chapter summary */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-6">
{/* Top navigation */}
<ChapterNav
lila={lila}
chapters={lilaInfo.chapters}
current={chapterNum}
chapterTitle={chapterInfo.title}
verseIndex={verseIndex}
/>
<p className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
Chapter Summary
</p>
<Card id="book-content">
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{summary.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<ChapterNav
lila={lila}
chapters={lilaInfo.chapters}
current={chapterNum}
chapterTitle={chapterInfo.title}
verseIndex={verseIndex}
/>
</main>
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { notFound } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { FindInPage } from "@/components/layout/find-in-page";
import { ExtractedVerseCount } from "../../extracted-verse-count";
import { getBookMeta, getProseChapter } 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 bookTitle = BOOK_NAMES["cc"] || "Śrī Caitanya-caritāmṛta";
// A non-līlā segment (front-flap, preface, introduction) is front matter.
if (!CC_LILAS[lila]) {
const segment = await getProseChapter("cc", 0, lila);
if (segment) {
return buildOgMeta({ title: bookTitle, detail: segment.title });
}
}
return buildOgMeta({ title: bookTitle, detail: CC_LILAS[lila] || lila });
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export default async function LilaPage({
params,
}: {
params: Promise<{ lila: string }>;
}) {
const { lila } = await params;
const meta = await getBookMeta("cc");
if (!meta) notFound();
const lilas = meta.lilas as LilaInfo[];
const lilaInfo = lilas.find((l) => l.key === lila);
// Front matter — the param is not a līlā key, so resolve it as a prose
// intro-segment (front-flap, preface, introduction).
if (!lilaInfo) {
const segment = await getProseChapter("cc", 0, lila);
if (!segment) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/cc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Caitanya-caritāmṛta
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{segment.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card id="book-content">
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
const totalVerses = lilaInfo.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/cc"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrī Caitanya-caritāmṛta
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{lilaInfo.name}
</h1>
<p className="mt-1 text-muted-foreground">
{lilaInfo.totalChapters} chapters ·{" "}
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{lilaInfo.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/cc/${lila}/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">{ch.title}</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getBookMeta, getProseChapters } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ExtractedVerseCount } from "../extracted-verse-count";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type LilaInfo = {
key: string;
name: string;
totalChapters: number;
totalVerses: number;
chapters: ChapterInfo[];
};
export function generateMetadata(): Metadata {
return ogForBook("cc");
}
export default async function CcPage() {
const [meta, introSegments] = await Promise.all([
getBookMeta("cc"),
getProseChapters("cc", 0, "intro-segment"),
]);
if (!meta) {
return (
<div className="p-8 text-center text-muted-foreground">
Book metadata not found.
</div>
);
}
const lilas = meta.lilas as LilaInfo[];
const title = meta.title as string;
const author = meta.author as string;
return (
<div className="min-h-screen bg-background">
<JsonLd data={bookJsonLd({ path: "/books/cc", name: "Śrī Caitanya-caritāmṛta" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
<p className="mt-1 text-muted-foreground">{author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{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 py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/cc/${seg.slug}`}
className="flex px-5 py-4 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{seg.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{lilas.map((lila) => {
const totalVerses = lila.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<li key={lila.key}>
<Link
href={`/books/cc/${lila.key}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
{lila.name}
</Badge>
<p className="font-medium text-foreground">
{lila.totalChapters} chapters
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount
count={totalVerses}
label="extracted verses"
/>
</span>
</Link>
</li>
);
})}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+497
View File
@@ -0,0 +1,497 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useEffect, useState } from "react";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
import { MobileNav, type MobileNavGroup } from "@/components/layout/mobile-nav";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
import { BOOK_NAMES } from "@/lib/constants";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Synonym = {
word: string;
meanings: string[];
};
type Verse = {
id: string;
book: string;
division: string | null;
chapter: number;
verse: number;
verseEnd: number | null;
reference: string;
bookTitle: string;
devanagari: string;
bengali: string | null;
transliteration: string;
synonyms: Synonym[];
translation: string;
purport: string[];
};
type VerseIndexEntry = {
verse: number;
verseEnd?: number;
reference: string;
};
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
type ChapterViewerProps = {
/** The first verse (SSR'd), shown before any client-side fetch */
initialVerse: Verse;
/** The verse number of the initial verse */
initialVerseNum: number;
/** Lightweight index of ALL verses in the chapter (for the sidebar) */
verseIndex: VerseIndexEntry[];
/** Book key */
book: string;
/** Division: null for BG, canto string for SB, lila key for CC */
division: string | null;
/** Chapter number */
chapter: number;
/** Whether to show the Bengali script field (CC only) */
showBengali?: boolean;
/** Base path for verse URLs, e.g. "/books/bg/1" — verse number is appended */
basePath: string;
/** Mobile nav groups for the drawer */
mobileNavGroups?: MobileNavGroup[];
/** Book title for the mobile nav drawer header */
mobileNavBookTitle?: string;
};
// ---------------------------------------------------------------------------
// Navigation buttons (reused at top and bottom)
// ---------------------------------------------------------------------------
function VerseNav({
currentIdx,
total,
onPrev,
onNext,
verseIndex,
mobileNavGroups,
currentVerseSlug,
mobileNavBookTitle,
}: {
currentIdx: number;
total: number;
onPrev: () => void;
onNext: () => void;
verseIndex: VerseIndexEntry[];
mobileNavGroups?: MobileNavGroup[];
currentVerseSlug?: string;
mobileNavBookTitle?: string;
}) {
const hasPrev = currentIdx > 0;
const hasNext = currentIdx < total - 1;
const current = verseIndex[currentIdx];
const label = current
? current.verseEnd
? `${current.verse}–${current.verseEnd}`
: String(current.verse)
: "";
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
{mobileNavGroups && mobileNavGroups.length > 0 && (
<MobileNav
groups={mobileNavGroups}
currentSlug={currentVerseSlug ?? String(current?.verse ?? "")}
bookTitle={mobileNavBookTitle}
/>
)}
<Button
variant="outline"
size="sm"
disabled={!hasPrev}
onClick={onPrev}
className="gap-1.5"
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline">Previous</span>
</Button>
</div>
<span className="text-sm text-muted-foreground">
Verse {label} of {total}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasNext}
onClick={onNext}
className="gap-1.5"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="size-4" />
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Verse card
// ---------------------------------------------------------------------------
function VerseCard({
verse,
showBengali,
}: {
verse: Verse;
showBengali: boolean;
}) {
return (
<Card id={`verse-${verse.verse}`} className="">
<CardHeader className="border-b border-border bg-muted/50 py-3">
<Badge variant="secondary">{verse.reference}</Badge>
</CardHeader>
<CardContent className="space-y-5 pt-5">
{/* Bengali (CC only) */}
{showBengali && verse.bengali && (
<div className="text-center">
<p className="text-xl leading-relaxed whitespace-pre-line font-serif text-green-800 dark:text-green-400">
{verse.bengali}
</p>
</div>
)}
{/* Devanagari */}
<div className="text-center">
<p className="text-xl leading-relaxed text-red-800 dark:text-white whitespace-pre-line font-serif">
{verse.devanagari}
</p>
</div>
{/* Transliteration */}
<div className="text-center">
<p className="text-base italic text-muted-foreground whitespace-pre-line">
{verse.transliteration}
</p>
</div>
{/* Synonyms */}
{verse.synonyms.length > 0 && (
<div>
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
Synonyms
</h3>
<p className="text-base text-foreground leading-relaxed">
{verse.synonyms.map((s, i) => (
<span key={i}>
<span className="italic text-foreground">{s.word}</span>—
{s.meanings.join(", ")}
{i < verse.synonyms.length - 1 ? "; " : "."}
</span>
))}
</p>
</div>
)}
{/* Translation */}
<div>
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
Translation
</h3>
<p
className="text-base text-foreground leading-relaxed font-medium"
dangerouslySetInnerHTML={{ __html: verse.translation }}
/>
</div>
{/* Purport */}
{verse.purport.length > 0 && (
<div>
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-2">
Purport
</h3>
<div className="text-base text-foreground leading-relaxed space-y-3">
{verse.purport.map((p, i) =>
// Cited ślokas come wrapped in <blockquote class="shloka"> — a
// block element that can't live inside a <p>, so render those
// in a <div> (styled centered + accent via globals.css); plain
// prose paragraphs stay as <p>.
/^\s*<blockquote/i.test(p) ? (
<div key={i} dangerouslySetInnerHTML={{ __html: p }} />
) : (
<p key={i} dangerouslySetInnerHTML={{ __html: p }} />
),
)}
</div>
</div>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// ChapterViewer
// ---------------------------------------------------------------------------
export function ChapterViewer({
initialVerse,
initialVerseNum,
verseIndex,
book,
division,
chapter,
showBengali = false,
basePath,
mobileNavGroups,
mobileNavBookTitle,
}: ChapterViewerProps) {
const router = useRouter();
// Verse cache: verseNum → Verse data
const [cache, setCache] = useState<Map<number, Verse>>(() => {
const map = new Map<number, Verse>();
map.set(initialVerseNum, initialVerse);
return map;
});
const [loading, setLoading] = useState(false);
// Current verse number — driven by the initialVerseNum from the server
// (which reads it from the [verse] path param)
const currentVerseNum = initialVerseNum;
// Current index in the verseIndex array
const currentIdx = useMemo(
() => verseIndex.findIndex((v) => v.verse === currentVerseNum),
[verseIndex, currentVerseNum],
);
// The verse data to display
const currentVerse = cache.get(currentVerseNum) ?? null;
// Reset cache when route changes to a different chapter
useEffect(() => {
const map = new Map<number, Verse>();
map.set(initialVerseNum, initialVerse);
setCache(map);
}, [initialVerse, initialVerseNum]);
// Fetch a verse if it's not in the cache
const fetchVerse = useCallback(
async (verseNum: number) => {
if (cache.has(verseNum)) return cache.get(verseNum)!;
setLoading(true);
const params = new URLSearchParams({
chapter: String(chapter),
verse: String(verseNum),
});
if (division !== null) {
params.set("division", division);
}
try {
const res = await fetch(`/api/books/${book}/verses?${params}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json();
// The API returns the verse in a paginated wrapper or an array
const verse: Verse | undefined = Array.isArray(data.verses)
? data.verses[0]
: Array.isArray(data)
? data[0]
: undefined;
if (verse) {
setCache((prev) => {
const next = new Map(prev);
next.set(verseNum, verse);
return next;
});
return verse;
}
} catch (err) {
console.error("Failed to fetch verse:", err);
} finally {
setLoading(false);
}
return null;
},
[cache, book, division, chapter],
);
// Navigate to a verse — update the URL path and fetch if needed
const navigateTo = useCallback(
async (verseNum: number) => {
// Prefetch the verse data before navigating
if (!cache.has(verseNum)) {
await fetchVerse(verseNum);
}
// Navigate to the new path-based URL
router.push(`${basePath}/${verseNum}`, { scroll: false });
// Scroll to top of the verse
window.scrollTo({ top: 0, behavior: "smooth" });
},
[basePath, router, cache, fetchVerse],
);
// Fetch current verse on mount/URL change if not cached
useEffect(() => {
if (!cache.has(currentVerseNum)) {
fetchVerse(currentVerseNum);
}
}, [currentVerseNum, cache, fetchVerse]);
// Prefetch adjacent verses
useEffect(() => {
if (currentIdx > 0) {
const prevNum = verseIndex[currentIdx - 1].verse;
if (!cache.has(prevNum)) fetchVerse(prevNum);
}
if (currentIdx < verseIndex.length - 1) {
const nextNum = verseIndex[currentIdx + 1].verse;
if (!cache.has(nextNum)) fetchVerse(nextNum);
}
}, [currentIdx, verseIndex, cache, fetchVerse]);
// Keyboard navigation
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
if (e.key === "ArrowLeft" && currentIdx > 0) {
e.preventDefault();
navigateTo(verseIndex[currentIdx - 1].verse);
} else if (e.key === "ArrowRight" && currentIdx < verseIndex.length - 1) {
e.preventDefault();
navigateTo(verseIndex[currentIdx + 1].verse);
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [currentIdx, verseIndex, navigateTo]);
const goPrev = useCallback(() => {
if (currentIdx > 0) navigateTo(verseIndex[currentIdx - 1].verse);
}, [currentIdx, verseIndex, navigateTo]);
const goNext = useCallback(() => {
if (currentIdx < verseIndex.length - 1)
navigateTo(verseIndex[currentIdx + 1].verse);
}, [currentIdx, verseIndex, navigateTo]);
return (
<>
{/* Structured data for the currently-shown verse (SSR'd for the initial
verse, updates on client navigation). */}
{currentVerse ? (
<JsonLd
data={articleJsonLd({
path: `${basePath}/${currentVerseNum}`,
headline: `${BOOK_NAMES[book] ?? book} ${currentVerse.reference}`,
description: currentVerse.translation,
isPartOf: BOOK_NAMES[book] ?? book,
})}
/>
) : null}
{/* ---- Sidebar: Verse Index ---- */}
{/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for
the site footer so the sidebar doesn't slide behind it when the
page is scrolled to the bottom. (89 sticky-top + 76 bottom gap
= 165, hence calc(100vh - 165px).) */}
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-165px)] overflow-y-auto py-8 pr-4">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Verses
</h2>
<nav className="grid grid-cols-3 gap-1">
{verseIndex.map((v) => {
const isActive = currentVerseNum === v.verse;
const label = v.verseEnd
? `${v.verse}–${v.verseEnd}`
: String(v.verse);
return (
<a
key={v.verse}
href={`${basePath}/${v.verse}`}
onClick={(e) => {
e.preventDefault();
navigateTo(v.verse);
}}
className={`px-2 py-1 rounded-md text-sm tabular-nums transition-colors ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{label}
</a>
);
})}
</nav>
</aside>
{/* ---- Main: Single Verse ---- */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-6">
{/* Top navigation */}
<VerseNav
currentIdx={currentIdx}
total={verseIndex.length}
onPrev={goPrev}
onNext={goNext}
verseIndex={verseIndex}
mobileNavGroups={mobileNavGroups}
currentVerseSlug={String(currentVerseNum)}
mobileNavBookTitle={mobileNavBookTitle}
/>
{/* Verse content — id targeted by each page header's FindInPage. */}
<div id="book-content">
{loading && !currentVerse ? (
<div className="flex items-center justify-center py-24 text-muted-foreground gap-2">
<Loader2 className="size-5 animate-spin" />
<span>Loading verse…</span>
</div>
) : currentVerse ? (
<VerseCard verse={currentVerse} showBengali={showBengali} />
) : (
<div className="text-center py-24 text-muted-foreground">
Verse not found.
</div>
)}
</div>
{/* Bottom navigation */}
<VerseNav
currentIdx={currentIdx}
total={verseIndex.length}
onPrev={goPrev}
onNext={goNext}
verseIndex={verseIndex}
mobileNavGroups={mobileNavGroups}
currentVerseSlug={String(currentVerseNum)}
mobileNavBookTitle={mobileNavBookTitle}
/>
</main>
</>
);
}
@@ -0,0 +1,338 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/ds/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Dialectic Spiritualism" })} />
<PageHeader sticky>
<Link
href="/books/ds"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Dialectic Spiritualism
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Chapter {parentChapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getDsMeta, getDsIntroSegments } from "@/lib/ds";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/ds", name: "Dialectic Spiritualism" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
{meta.subtitle && (
<p className="mt-1 text-lg text-muted-foreground">{meta.subtitle}</p>
)}
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ds/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters — hierarchical parent → sub-chapter structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{ch.number}. {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/ds/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/ej/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Easy Journey to Other Planets" })} />
<PageHeader sticky>
<Link
href="/books/ej"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Easy Journey to Other Planets
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+111
View File
@@ -0,0 +1,111 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/ej", name: "Easy Journey to Other Planets" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ej/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ej/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/ek/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Elevation to Kṛṣṇa Consciousness" })} />
<PageHeader sticky>
<Link
href="/books/ek"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Elevation to Kṛṣṇa Consciousness
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getEkMeta, getEkIntroSegments } from "@/lib/ek";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/ek", name: "Elevation to Kṛṣṇa Consciousness" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ek/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ek/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,364 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
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">
<JsonLd data={articleJsonLd({ path: `/books/gg/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Gītār Gān" })} />
<PageHeader sticky>
<Link
href="/books/gg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Gītār Gān
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+143
View File
@@ -0,0 +1,143 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/gg", name: "Gītār Gān" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/gg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/gg/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/hkh/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "The Kṛṣṇa Consciousness Handbook" })} />
<PageHeader sticky>
<Link
href="/books/hkh"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Kṛṣṇa Consciousness Handbook
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getHkhMeta, getHkhIntroSegments } from "@/lib/hkh";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/hkh", name: "The Kṛṣṇa Consciousness Handbook" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/hkh/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/hkh/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,469 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/iso/${slug}`, headline: content.title ?? "Śrī Īśopaniṣad", isPartOf: "Śrī Īśopaniṣad" })} />
<PageHeader sticky>
<Link
href="/books/iso"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrī Īśopaniṣad
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{verse && (
<>
<span className="text-primary">{verse.reference}: </span>
{verse.bookTitle}
</>
)}
{intro && intro.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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 id="book-content">
<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>
);
}
+179
View File
@@ -0,0 +1,179 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/iso", name: "Śrī Īśopaniṣad" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{(frontPages || introduction) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontPages && (
<li>
<Link
href="/books/iso/intro-front-pages"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{frontPages.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{introduction && (
<li>
<Link
href="/books/iso/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{introduction.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Mantras */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Mantras
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{/* Invocation */}
{invocation && (
<li>
<Link
href="/books/iso/invocation"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Invocation
</span>
<p className="font-medium text-foreground">
{invocation.title}
</p>
</div>
</Link>
</li>
)}
{/* Mantras 1-18 */}
{numberedMantras.map((m) => (
<li key={m.number}>
<Link
href={`/books/iso/${m.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Mantra {m.number}
</span>
<p className="font-medium text-foreground">{m.title}</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* About the Author */}
{aboutAuthor && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
About the Author
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/iso/intro-about-the-author"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{aboutAuthor.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,294 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
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">
<JsonLd data={articleJsonLd({ path: `/books/kb/${vol}/${slug}`, headline: chapter.title, description: chapter.paragraphs?.[0], isPartOf: "Kṛṣṇa, The Supreme Personality of Godhead" })} />
<PageHeader sticky>
<Link
href={`/books/kb/${vol}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Volume {vol}
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterLabel && (
<span className="text-primary">{chapterLabel}: </span>
)}
{chapter.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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/layout/page-header";
import { Card, CardContent } from "@/components/ui/card";
export async function generateMetadata({
params,
}: {
params: Promise<{ volume: string }>;
}): Promise<Metadata> {
const { volume } = await params;
const title = BOOK_NAMES["kb"] || "KB";
return buildOgMeta({ title, detail: `Volume ${volume}` });
}
export default async function KbVolumePage({
params,
}: {
params: Promise<{ volume: string }>;
}) {
const { volume } = await params;
const vol = parseInt(volume);
if (isNaN(vol) || vol < 1 || vol > 2) notFound();
const meta = await getBookMeta("kb");
if (!meta) notFound();
const title = meta.title as string;
const [introSegments, chapters] = await Promise.all([
getProseChapters("kb", vol, "intro-segment"),
getProseChapters("kb", vol, "chapter"),
]);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/kb"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {title}
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Volume {vol}
</h1>
<p className="mt-1 text-muted-foreground">{chapters.length} chapters</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/kb/${vol}/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{chapters.map((ch) => (
<li key={ch.slug}>
<Link
href={`/books/kb/${vol}/${ch.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.chapter}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/kb", name: "Kṛṣṇa, The Supreme Personality of Godhead" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">{title}</h1>
<p className="mt-1 text-muted-foreground">{author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
{description && (
<p className="text-muted-foreground mb-6">{description}</p>
)}
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{volumes.map((vol) => (
<li key={vol.number}>
<Link
href={`/books/kb/${vol.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
Volume {vol.number}
</Badge>
<p className="font-medium text-foreground">
{vol.totalChapters} chapters
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
@@ -0,0 +1,368 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/lcl/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Life Comes from Life" })} />
<PageHeader sticky>
<Link
href="/books/lcl"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Life Comes from Life
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+143
View File
@@ -0,0 +1,143 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/lcl", name: "Life Comes from Life" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/lcl/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter (About the Author) */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lcl/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,342 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
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">
<JsonLd data={articleJsonLd({ path: `/books/lob/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Light of the Bhāgavata" })} />
<PageHeader sticky>
<Link
href="/books/lob"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Light of the Bh&#257;gavata
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Text {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLobMeta, getLobIntroSegments } from "@/lib/lob";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/lob", name: "Light of the Bhāgavata" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/lob/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Texts */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Texts
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/lob/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Text {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,304 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
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">
<JsonLd data={articleJsonLd({ path: `/books/mg/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Kṛṣṇa Consciousness: The Matchless Gift" })} />
<PageHeader sticky>
<Link
href="/books/mg"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Kṛṣṇa Consciousness: The Matchless Gift
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMgMeta, getMgIntroSegments } from "@/lib/mg";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/mg", name: "Kṛṣṇa Consciousness: The Matchless Gift" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/mg/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mg/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,219 @@
import fs from "fs";
import path from "path";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/(reader)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/layout/mobile-nav";
import type { Metadata } from "next";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getVerseWithFallback(parseInt(chapter), parseInt(verse));
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Mukunda-mālā-stotra",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("mm", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
type MmMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("mm");
if (meta) return meta as unknown as MmMeta;
const introPath = path.join(MM_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as MmMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("mm", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("mm", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
MM_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `mm-${chapter}-${verse}`,
book: "mm",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const mmMeta = await getMetaWithFallback();
if (!mmMeta) notFound();
const chapterInfo = mmMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/mm/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mm"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Mukunda-mālā-stotra
</Link>
<div className="mt-2 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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,121 @@
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/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import Link from "next/link";
import fs from "fs";
import path from "path";
const MM_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mm");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({ title: BOOK_NAMES["mm"], detail: `Chapter ${chapter}` });
}
export default async function MmChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(MM_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/mm"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Mukunda-mālā-stotra
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{segment.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card id="book-content">
<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}`);
}
+166
View File
@@ -0,0 +1,166 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/mm", name: "Mukunda-mālā-stotra" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introduction && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/mm/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mm/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/mog/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Message of Godhead" })} />
<PageHeader sticky>
<Link
href="/books/mog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Message of Godhead
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMogMeta, getMogIntroSegments } from "@/lib/mog";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/mog", name: "Message of Godhead" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/mog/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/mog/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,224 @@
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/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import { notFound } from "next/navigation";
import {
getBookMeta,
getSingleVerse,
getChapterVerseIndex,
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { ChapterViewer } from "@/app/(app)/(reader)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/layout/mobile-nav";
type NbsMeta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
// Fallback functions that read from local JSON when DB is empty
async function getMetaWithFallback() {
const meta = await getBookMeta("nbs");
if (meta) return meta as unknown as NbsMeta;
const introPath = path.join(NBS_JSON_DIR, "intro.json");
if (!fs.existsSync(introPath)) return null;
try {
return JSON.parse(fs.readFileSync(introPath, "utf-8")) as NbsMeta;
} catch {
return null;
}
}
async function getVerseIndexWithFallback(
chapter: number,
): Promise<VerseIndexEntry[]> {
const index = await getChapterVerseIndex("nbs", null, chapter);
if (index.length > 0) return index;
// Fallback: read verse files from local JSON
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return [] as VerseIndexEntry[];
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
return files.map((f): VerseIndexEntry => {
const v = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
return {
verse: v.verse,
verseEnd: v.verse_end || null,
reference: v.reference,
};
});
}
async function getVerseWithFallback(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const v = await getSingleVerse("nbs", null, chapter, verse);
if (v) return v;
// Fallback: read from local JSON
const versesDir = path.join(
NBS_JSON_DIR,
"chapters",
String(chapter).padStart(2, "0"),
"verses",
);
if (!fs.existsSync(versesDir)) return null;
const files = fs
.readdirSync(versesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const f of files) {
const data = JSON.parse(fs.readFileSync(path.join(versesDir, f), "utf-8"));
if (data.verse === verse) {
return {
id: `nbs-${chapter}-${verse}`,
book: "nbs",
division: null,
chapter: data.chapter,
verse: data.verse,
verseEnd: data.verse_end || null,
reference: data.reference,
bookTitle: data.book_title,
devanagari: data.devanagari,
bengali: null,
transliteration: data.transliteration,
synonyms: data.synonyms,
translation: data.translation,
purport: data.purport,
};
}
}
return null;
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"nbs",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Nārada-bhakti-sūtra",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("nbs", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const nbsMeta = await getMetaWithFallback();
if (!nbsMeta) notFound();
const chapterInfo = nbsMeta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getVerseIndexWithFallback(parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getVerseWithFallback(parseInt(chapter), requestedVerse);
if (!verse) notFound();
// Build mobile nav groups — verses for the current chapter
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/nbs/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/nbs"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Nārada-bhakti-sūtra
</Link>
<div className="mt-2 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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,124 @@
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/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import Link from "next/link";
import fs from "fs";
import path from "path";
const NBS_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "nbs");
type IntroSegment = {
slug: string;
title: string;
paragraphs: string[];
};
export async function generateMetadata({
params,
}: {
params: Promise<{ chapter: string }>;
}): Promise<Metadata> {
const { chapter } = await params;
return buildOgMeta({
title: BOOK_NAMES["nbs"],
detail: `Chapter ${chapter}`,
});
}
export default async function NbsChapterPage({
params,
}: {
params: Promise<{ chapter: string }>;
}) {
const { chapter } = await params;
// Handle intro segments (intro-introduction)
if (chapter.startsWith("intro-")) {
const rawSlug = chapter.replace(/^intro-/, "");
const filePath = path.join(NBS_JSON_DIR, "intro", `${rawSlug}.json`);
if (!fs.existsSync(filePath)) notFound();
let segment: IntroSegment;
try {
segment = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch {
notFound();
}
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/nbs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Nārada-bhakti-sūtra
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{segment.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card id="book-content">
<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}`);
}
+166
View File
@@ -0,0 +1,166 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/nbs", name: "Nārada-bhakti-sūtra" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
{meta.description && (
<p className="mt-2 text-sm text-muted-foreground">
{meta.description}
</p>
)}
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Front Matter */}
{introduction && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/nbs/intro-introduction"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/nbs/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,333 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/nod/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "The Nectar of Devotion" })} />
<PageHeader sticky>
<Link
href="/books/nod"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Nectar of Devotion
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+151
View File
@@ -0,0 +1,151 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/nod", name: "The Nectar of Devotion" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/nod/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts & Chapters */}
{meta.parts.map((part) => {
const partChapters = part.chapters
.map((num) => meta.chapters.find((ch) => ch.number === num))
.filter(Boolean) as { number: number; title: string }[];
return (
<div key={part.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{PART_LABELS[part.number] ?? `Part ${part.number}`}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{partChapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/nod/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
})}
{/* Concluding Words */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Conclusion
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/nod/concluding-words"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
Concluding Words
</p>
</div>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,458 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/noi/${slug}`, headline: content.title ?? "The Nectar of Instruction", isPartOf: "The Nectar of Instruction" })} />
<PageHeader sticky>
<Link
href="/books/noi"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Nectar of Instruction
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{verse && (
<>
<span className="text-primary">Text {verse.text}: </span>
{verse.title}
</>
)}
{intro && intro.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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 id="book-content">
<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>
);
}
+156
View File
@@ -0,0 +1,156 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/noi", name: "The Nectar of Instruction" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 italic text-muted-foreground">{meta.subtitle}</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{(frontPages || preface) && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontPages && (
<li>
<Link
href="/books/noi/intro-front-pages"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{frontPages.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
{preface && (
<li>
<Link
href="/books/noi/intro-preface"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{preface.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Texts */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Texts
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.texts.map((t) => (
<li key={t.number}>
<Link
href={`/books/noi/${t.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Text {t.number}
</span>
<p className="font-medium text-foreground">{t.title}</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Appendixes */}
{appendixes && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Appendixes
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/noi/intro-appendixes"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{appendixes.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/ochkm/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "On Chanting Hare Kṛṣṇa" })} />
<PageHeader sticky>
<Link
href="/books/ochkm"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; On Chanting the Hare Kṛṣṇa Mantra
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getOchkmMeta, getOchkmIntroSegments } from "@/lib/ochkm";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/ochkm", name: "On Chanting Hare Kṛṣṇa" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ochkm/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ochkm/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/owk/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "On the Way to Kṛṣṇa" })} />
<PageHeader sticky>
<Link
href="/books/owk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; On the Way to Kṛṣṇa
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getOwkMeta, getOwkIntroSegments } from "@/lib/owk";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/owk", name: "On the Way to Kṛṣṇa" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/owk/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/owk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { getAllBooks, type BookMeta } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
import { BooksGrid, type BookInfo } from "./books-grid";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Books",
detail: "Books by Śrīla Prabhupāda",
quote:
"Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, and 30+ other books with Devanagari, transliteration, synonyms, translations & purports.",
});
}
type ChapterEntry = { totalVerses: number };
type CantoEntry = { totalChapters: number; chapters: ChapterEntry[] };
type LilaEntry = { totalChapters: number; chapters: ChapterEntry[] };
interface ChapterBookMeta extends BookMeta {
totalChapters: number;
chapters: ChapterEntry[];
}
interface SbMeta extends BookMeta {
totalCantos: number;
cantos: CantoEntry[];
}
interface CcMeta extends BookMeta {
totalLilas: number;
lilas: LilaEntry[];
}
function buildBookList(allBooks: BookMeta[]): BookInfo[] {
const books: BookInfo[] = [];
for (const data of allBooks) {
const slug = data.book;
const title = data.title as string;
const author = data.author as string;
if (slug === "bg") {
const bg = data as ChapterBookMeta;
const totalVerses = bg.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
books.push({
slug,
title,
author,
description: `${bg.totalChapters} chapters`,
contentDescription:
"The essence of Vedic knowledge — Lord Kṛṣṇa's teachings to Arjuna on the battlefield of Kurukṣetra.",
totalVerses,
href: "/books/bg",
});
} else if (slug === "sb1962") {
const sb62 = data as ChapterBookMeta;
const totalVerses = sb62.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
books.push({
slug,
title,
author,
description: `${sb62.totalChapters} chapters · First Canto`,
contentDescription:
(data as { description?: string }).description ||
"The original 1962 Indian edition of the First Canto.",
totalVerses,
href: "/books/sb1962",
});
} else if (slug === "sb") {
const sb = data as SbMeta;
const totalChapters = sb.cantos.reduce(
(sum, c) => sum + c.totalChapters,
0,
);
const totalVerses = sb.cantos.reduce(
(sum, c) => sum + c.chapters.reduce((s, ch) => s + ch.totalVerses, 0),
0,
);
books.push({
slug,
title,
author,
description: `${sb.totalCantos} cantos, ${totalChapters} chapters`,
contentDescription:
"The ripened fruit of Vedic literature — the science of God in 18,000 verses.",
totalVerses,
href: "/books/sb",
});
} else if (slug === "cc") {
const cc = data as CcMeta;
const totalChapters = cc.lilas.reduce(
(sum, l) => sum + l.totalChapters,
0,
);
const totalVerses = cc.lilas.reduce(
(sum, l) => sum + l.chapters.reduce((s, ch) => s + ch.totalVerses, 0),
0,
);
books.push({
slug,
title,
author,
description: `${cc.totalLilas} līlās, ${totalChapters} chapters`,
contentDescription:
"The life and teachings of Lord Caitanya Mahāprabhu — the most complete theistic science.",
totalVerses,
href: "/books/cc",
});
} else {
// Generic handler for all other books — pulls description from metadata
const metaDesc = (data as { description?: string }).description || "";
const totalChapters = (data as { totalChapters?: number }).totalChapters;
const totalMantras = (data as { totalMantras?: number }).totalMantras;
const totalTexts = (data as { totalTexts?: number }).totalTexts;
const parts = (data as { parts?: unknown[] }).parts;
const volumes = (data as { volumes?: unknown[] }).volumes;
let badge = "";
if (totalMantras) badge = `${totalMantras} mantras`;
else if (totalTexts) badge = `${totalTexts} texts`;
else if (totalChapters && volumes)
badge = `${totalChapters} chapters · ${volumes.length} volumes`;
else if (totalChapters && parts)
badge = `${totalChapters} chapters · ${parts.length} parts`;
else if (totalChapters) badge = `${totalChapters} chapters`;
books.push({
slug,
title,
author,
description: badge,
contentDescription: metaDesc,
totalVerses: 0,
href: `/books/${slug}`,
});
}
}
return books;
}
export default async function BooksPage() {
const allBooks = await getAllBooks();
const books = buildBookList(allBooks);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Books</h1>
<p className="mt-1 text-muted-foreground">
Browse the books and teachings of Śrīla Prabhupāda
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<BooksGrid books={books} />
</main>
</div>
);
}
@@ -0,0 +1,267 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/pk/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Prabhupāda-Kṛpā" })} />
<PageHeader sticky>
<Link
href="/books/pk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Prayers of King Kulaśekhara
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Verse {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+75
View File
@@ -0,0 +1,75 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/pk", name: "Prabhupāda-Kṛpā" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters (Prayers) */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Prayers
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/pk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Verse {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/poy/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "The Perfection of Yoga" })} />
<PageHeader sticky>
<Link
href="/books/poy"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Perfection of Yoga
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPoyMeta, getPoyIntroSegments } from "@/lib/poy";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/poy", name: "The Perfection of Yoga" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/poy/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/poy/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,398 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/pqpa/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Perfect Questions, Perfect Answers" })} />
<PageHeader sticky>
<Link
href="/books/pqpa"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Perfect Questions Perfect Answers
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+168
View File
@@ -0,0 +1,168 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/pqpa", name: "Perfect Questions, Perfect Answers" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/pqpa/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/pqpa/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Concluding Words */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Conclusion
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
<li>
<Link
href="/books/pqpa/concluding-words"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
Concluding Words
</p>
</div>
</Link>
</li>
</ul>
</CardContent>
</Card>
</div>
{/* About the Author */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/pqpa/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/rv/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Rāja-vidyā: The King of Knowledge" })} />
<PageHeader sticky>
<Link
href="/books/rv"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Rāja-vidyā: The King of Knowledge
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+108
View File
@@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getRvMeta, getRvIntroSegments } from "@/lib/rv";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/rv", name: "Rāja-vidyā: The King of Knowledge" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/rv/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/rv/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,139 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/(reader)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/layout/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ canto: string; chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { canto, chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"sb",
canto,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrīmad-Bhāgavatam",
detail: `Canto ${canto}, Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("sb", {
division: canto,
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function SbVersePage({
params,
}: {
params: Promise<{ canto: string; chapter: string; verse: string }>;
}) {
const { canto, chapter, verse } = await params;
const meta = await getBookMeta("sb");
if (!meta) notFound();
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
const cantoInfo = cantos.find((c) => c.number === parseInt(canto));
if (!cantoInfo) notFound();
const chapterInfo = cantoInfo.chapters.find(
(ch) => ch.number === parseInt(chapter),
);
if (!chapterInfo) notFound();
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verse);
const verseData = await getSingleVerse(
"sb",
canto,
parseInt(chapter),
requestedVerse,
);
if (!verseData) notFound();
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${canto}.${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${canto}.${chapter}.${v.verse}`,
href: `/books/sb/${canto}/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href={`/books/sb/${canto}`}
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Canto {cantoInfo.number}: {cantoInfo.title}
</Link>
<div className="mt-2 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
Chapter {chapter}: {chapterInfo.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
<ChapterViewer
initialVerse={verseData}
initialVerseNum={requestedVerse}
verseIndex={verseIndex.map((v) => ({
verse: v.verse,
verseEnd: v.verseEnd ?? undefined,
reference: v.reference,
}))}
book="sb"
division={canto}
chapter={parseInt(chapter)}
showBengali={false}
basePath={`/books/sb/${canto}/${chapter}`}
mobileNavGroups={mobileNavGroups}
mobileNavBookTitle={`Chapter ${chapter}: ${chapterInfo.title}`}
/>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getChapterVerseIndex } from "@/lib/api";
import { buildOgMeta } from "@/lib/og";
export async function generateMetadata({
params,
}: {
params: Promise<{ canto: string; chapter: string }>;
}): Promise<Metadata> {
const { canto, chapter } = await params;
return buildOgMeta({
title: "Śrīmad-Bhāgavatam",
detail: `Canto ${canto}, Chapter ${chapter}`,
});
}
export default async function SbChapterPage({
params,
}: {
params: Promise<{ canto: string; chapter: string }>;
}) {
const { canto, chapter } = await params;
const verseIndex = await getChapterVerseIndex("sb", canto, parseInt(chapter));
if (verseIndex.length === 0) {
notFound();
}
redirect(`/books/sb/${canto}/${chapter}/${verseIndex[0].verse}`);
}
@@ -0,0 +1,147 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { notFound } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { FindInPage } from "@/components/layout/find-in-page";
import { ExtractedVerseCount } from "../../extracted-verse-count";
import { getBookMeta, getProseChapter } 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";
// A non-numeric segment (preface, introduction) is front matter.
if (isNaN(parseInt(canto))) {
const segment = await getProseChapter("sb", 0, canto);
if (segment) return buildOgMeta({ title, detail: segment.title });
}
return buildOgMeta({ title, detail: `Canto ${canto}` });
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export default async function CantoPage({
params,
}: {
params: Promise<{ canto: string }>;
}) {
const { canto } = await params;
const cantoNum = parseInt(canto);
// Front matter — a non-numeric param (preface, introduction) resolves to a
// prose intro-segment.
if (isNaN(cantoNum)) {
const segment = await getProseChapter("sb", 0, canto);
if (!segment) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/sb"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Śrīmad-Bhāgavatam
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{segment.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card id="book-content">
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{segment.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
</main>
</div>
);
}
const meta = await getBookMeta("sb");
if (!meta) notFound();
const cantos = (meta as unknown as { cantos: CantoInfo[] }).cantos;
const cantoInfo = cantos.find((c) => c.number === cantoNum);
if (!cantoInfo) notFound();
const totalVerses = cantoInfo.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/books/sb"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrīmad-Bhāgavatam
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Canto {cantoInfo.number}: {cantoInfo.title}
</h1>
<p className="mt-1 text-muted-foreground">
{cantoInfo.totalChapters} chapters ·{" "}
<ExtractedVerseCount count={totalVerses} label="extracted verses" />
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{cantoInfo.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/sb/${cantoNum}/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">{ch.title}</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ExtractedVerseCount } from "../extracted-verse-count";
import { getBookMeta, getProseChapters } from "@/lib/api";
import { ogForBook } from "@/lib/og";
import { notFound } from "next/navigation";
export function generateMetadata(): Metadata {
return ogForBook("sb");
}
type ChapterInfo = {
number: number;
title: string;
totalVerses: number;
};
type CantoInfo = {
number: number;
title: string;
totalChapters: number;
chapters: ChapterInfo[];
};
export default async function SbPage() {
const [meta, introSegments] = await Promise.all([
getBookMeta("sb"),
getProseChapters("sb", 0, "intro-segment"),
]);
if (!meta) notFound();
const intro = meta as {
book: string;
title: string;
author: string;
totalCantos: number;
cantos: CantoInfo[];
};
return (
<div className="min-h-screen bg-background">
<JsonLd data={bookJsonLd({ path: "/books/sb", name: "Śrīmad-Bhāgavatam" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{intro.title}
</h1>
<p className="mt-1 text-muted-foreground">{intro.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{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 py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/sb/${seg.slug}`}
className="flex px-5 py-4 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{seg.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{intro.cantos.map((canto) => {
const totalVerses = canto.chapters.reduce(
(sum, ch) => sum + ch.totalVerses,
0,
);
return (
<li key={canto.number}>
<Link
href={`/books/sb/${canto.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<Badge variant="secondary" className="mb-1">
Canto {canto.number}
</Badge>
<p className="font-medium text-foreground">
{canto.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{canto.totalChapters} chapters ·{" "}
<ExtractedVerseCount
count={totalVerses}
label="extracted verses"
/>
</span>
</Link>
</li>
);
})}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
@@ -0,0 +1,136 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { FindInPage } from "@/components/layout/find-in-page";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/(app)/(reader)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/layout/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
export const dynamic = "force-dynamic";
type Sb1962Meta = {
book: string;
title: string;
author: string;
totalChapters: number;
chapters: { number: number; title: string; totalVerses: number }[];
};
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ chapter: string; verse: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { chapter, verse } = await params;
const sp = await searchParams;
const v = await getSingleVerse(
"sb1962",
null,
parseInt(chapter),
parseInt(verse),
);
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
if (quote) {
return buildOgMeta({
title: "Śrīmad-Bhāgavatam (1962)",
detail: `Chapter ${chapter}, Verse ${verse}`,
quote,
});
}
return ogForVerse("sb1962", {
chapter: parseInt(chapter),
verse: parseInt(verse),
});
}
export default async function VersePage({
params,
}: {
params: Promise<{ chapter: string; verse: string }>;
}) {
const { chapter, verse: verseParam } = await params;
const meta = await getBookMeta("sb1962");
if (!meta) notFound();
const sb1962Meta = meta as unknown as Sb1962Meta;
const chapterInfo = sb1962Meta.chapters?.find(
(c) => c.number === parseInt(chapter),
);
if (!chapterInfo?.title) notFound();
const title: string = chapterInfo.title;
const verseIndex = await getChapterVerseIndex(
"sb1962",
null,
parseInt(chapter),
);
if (verseIndex.length === 0) notFound();
const requestedVerse = parseInt(verseParam);
const verse = await getSingleVerse(
"sb1962",
null,
parseInt(chapter),
requestedVerse,
);
if (!verse) notFound();
const mobileNavGroups: MobileNavGroup[] = [
{
title: "Verses",
items: verseIndex.map((v) => ({
slug: String(v.verse),
label: v.verseEnd
? `Verse ${chapter}.${v.verse}–${v.verseEnd}`
: `Verse ${chapter}.${v.verse}`,
href: `/books/sb1962/${chapter}/${v.verse}`,
type: "verse",
})),
},
];
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/books/sb1962"
className="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Śrīmad-Bhāgavatam (1962)
</Link>
<div className="mt-2 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
Chapter {chapter}: {title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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}`);
}
+78
View File
@@ -0,0 +1,78 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/sb1962", name: "Śrīmad-Bhāgavatam (1962 Edition)" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{intro.title}
</h1>
<p className="mt-1 text-muted-foreground">{intro.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="divide-y divide-border py-0">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{intro.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/sb1962/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">{ch.title}</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
<ExtractedVerseCount count={ch.totalVerses} />
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
</div>
);
}
@@ -0,0 +1,376 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/ssr/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "The Science of Self-Realization" })} />
<PageHeader sticky>
<Link
href="/books/ssr"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; The Science of Self-Realization
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Chapter {parentChapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getSsrMeta, getSsrIntroSegments } from "@/lib/ssr";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/ssr", name: "The Science of Self-Realization" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ssr/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters — hierarchical parent → sub-chapter structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{ch.number}. {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/ssr/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
{/* Back Matter (About the Author) */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/ssr/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,339 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/sva/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Songs of the Vaiṣṇava Ācāryas" })} />
<PageHeader sticky>
<Link
href="/books/sva"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Songs of the Vaiṣṇava Ācāryas
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{parentChapterNum != null && (
<span className="text-primary">Part {parentChapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+140
View File
@@ -0,0 +1,140 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getSvaMeta, getSvaIntroSegments } from "@/lib/sva";
import { ogForBook } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/sva", name: "Songs of the Vaiṣṇava Ācāryas" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts — hierarchical parent → song (sub-chapter) structure */}
{meta.chapters.map((ch) => (
<div key={ch.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Part {ch.number}: {ch.title}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{ch.subChapters.map((sub) => (
<li key={sub.slug}>
<Link
href={`/books/sva/${sub.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<p className="font-medium text-foreground">
{sub.title}
</p>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
))}
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/sva/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,352 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/tlc/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Teachings of Lord Caitanya" })} />
<PageHeader sticky>
<Link
href="/books/tlc"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Lord Caitanya
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+124
View File
@@ -0,0 +1,124 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/tlc", name: "Teachings of Lord Caitanya" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground italic">
{meta.subtitle}
</p>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{introSegments && introSegments.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{introSegments.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlc/${seg.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Parts & Chapters */}
{meta.parts.map((part) => {
const partChapters = part.chapters
.map((num) => meta.chapters.find((ch) => ch.number === num))
.filter(Boolean) as { number: number; title: string }[];
return (
<div key={part.number}>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
{PART_LABELS[part.number] ?? `Part ${part.number}`}
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{partChapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tlc/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
})}
</main>
</div>
);
}
@@ -0,0 +1,373 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/tlk/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Teachings of Lord Kapila" })} />
<PageHeader sticky>
<Link
href="/books/tlk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Lord Kapila
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+143
View File
@@ -0,0 +1,143 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/tlk", name: "Teachings of Lord Kapila" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tlk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tlk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,373 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/tqk/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Teachings of Queen Kuntī" })} />
<PageHeader sticky>
<Link
href="/books/tqk"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Teachings of Queen Kuntī
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+143
View File
@@ -0,0 +1,143 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/tqk", name: "Teachings of Queen Kuntī" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Front Matter */}
{frontMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Front Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{frontMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/tqk/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
{/* Back Matter */}
{backMatter.length > 0 && (
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Back Matter
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{backMatter.map((seg) => (
<li key={seg.slug}>
<Link
href={`/books/tqk/${seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{seg.title}
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
</span>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,308 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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">
<JsonLd data={articleJsonLd({ path: `/books/ttp/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Teachings of Lord Kapila" })} />
<PageHeader sticky>
<Link
href="/books/ttp"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Transcendental Teachings of Prahlāda Mahārāja
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{chapterNum != null && (
<span className="text-primary">Chapter {chapterNum}: </span>
)}
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+75
View File
@@ -0,0 +1,75 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/ttp", name: "Teachings of Lord Kapila" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Description */}
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
{/* Chapters */}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/ttp/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<span className="text-sm text-primary font-medium">
Chapter {ch.number}
</span>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,225 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getVaContent,
getVaSidebarItems,
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/mobile-nav";
import { FindInPage } from "@/components/layout/find-in-page";
// ---------------------------------------------------------------------------
// 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;
return (
<div className="min-h-screen bg-background">
<JsonLd data={articleJsonLd({ path: `/books/va/${slug}`, headline: content.title, description: content.paragraphs?.[0], isPartOf: "Vedānta-sūtra Transcript" })} />
<PageHeader sticky>
<Link
href="/books/va"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Vaiśiṣṭyāṣṭakam
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">
{content.title}
</h1>
<FindInPage targetId="book-content" className="shrink-0 mt-0.5" />
</div>
</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 id="book-content">
<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>
);
}
+70
View File
@@ -0,0 +1,70 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { bookJsonLd } from "@/lib/jsonld";
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">
<JsonLd data={bookJsonLd({ path: "/books/va", name: "Vedānta-sūtra Transcript" })} />
<PageHeader>
<Link
href="/books"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Books
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
{meta.title}
</h1>
<p className="mt-1 text-muted-foreground">{meta.author}</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{meta.description && (
<p className="text-muted-foreground">{meta.description}</p>
)}
<div>
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3">
Chapters
</h2>
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{meta.chapters.map((ch) => (
<li key={ch.number}>
<Link
href={`/books/va/${ch.number}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-0 hover:bg-muted transition-colors"
>
<div>
<p className="font-medium text-foreground">
{ch.title}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</main>
</div>
);
}
@@ -0,0 +1,588 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
Search,
ArrowUpDown,
ArrowDownAZ,
ArrowUpAZ,
Calendar,
CalendarArrowDown,
MapPin,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DiscipleSummary = {
id: string;
initiatedName: string;
suffix: string;
initiationDate: string | null;
initiationPlace: string | null;
isVerified: boolean;
};
export type LetterCount = { letter: string; count: number };
export type PlaceGroup = { name: string; count: number };
export type YearGroup = { year: number; count: number };
export type SuffixCount = { suffix: string; count: number };
type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
type Props = {
initialDisciples: DiscipleSummary[];
initialLetter: string;
activeSuffix: SuffixFilter;
letterCounts: LetterCount[];
suffixCounts: SuffixCount[];
places: PlaceGroup[];
years: YearGroup[];
totalCount: number;
filteredCount: number;
};
type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXY".split("");
const PAGE_SIZE = 60;
const SUFFIX_TABS: { key: SuffixFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "Dasa", label: "Dasa" },
{ key: "Devi Dasi", label: "Devi Dasi" },
];
// ---------------------------------------------------------------------------
// Diacritic-aware normalize
// ---------------------------------------------------------------------------
function normalize(text: string): string {
return text
.toLowerCase()
.replace(/ṛ/g, "r")
.replace(/ṝ/g, "r")
.replace(/ṣ/g, "s")
.replace(/ś/g, "s")
.replace(/ṭ/g, "t")
.replace(/ḍ/g, "d")
.replace(/ṇ/g, "n")
.replace(/ñ/g, "n")
.replace(/ṅ/g, "n")
.replace(/ḥ/g, "h")
.replace(/ṁ/g, "m")
.replace(/ṃ/g, "m")
.replace(/ḷ/g, "l")
.replace(/ā/g, "a")
.replace(/ī/g, "i")
.replace(/ū/g, "u")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extractYear(isoDate: string | null): number | null {
if (!isoDate) return null;
const year = parseInt(isoDate.slice(0, 4), 10);
return isNaN(year) ? null : year;
}
function formatLocationYear(
place: string | null,
date: string | null,
): string | null {
const year = extractYear(date);
const parts: string[] = [];
if (place) parts.push(place);
if (year) parts.push(String(year));
return parts.length > 0 ? parts.join(" · ") : null;
}
/**
* Build the href for letter + suffix navigation.
* Always preserves both params so the URL stays consistent.
*/
function buildHref(letter: string, suffix: SuffixFilter): string {
const params = new URLSearchParams();
params.set("letter", letter);
if (suffix !== "all") {
params.set("suffix", suffix);
}
return `/disciples?${params.toString()}`;
}
// ---------------------------------------------------------------------------
// Sort options
// ---------------------------------------------------------------------------
const SORT_OPTIONS: {
key: SortMode;
label: string;
icon: typeof ArrowDownAZ;
}[] = [
{ key: "name-asc", label: "Name A → Z", icon: ArrowDownAZ },
{ key: "name-desc", label: "Name Z → A", icon: ArrowUpAZ },
{ key: "date-asc", label: "Earliest first", icon: Calendar },
{ key: "date-desc", label: "Latest first", icon: CalendarArrowDown },
{ key: "place", label: "By place", icon: MapPin },
];
function sortDisciples(
data: DiscipleSummary[],
mode: SortMode,
): DiscipleSummary[] {
const sorted = [...data];
switch (mode) {
case "name-asc":
return sorted.sort((a, b) =>
a.initiatedName.localeCompare(b.initiatedName),
);
case "name-desc":
return sorted.sort((a, b) =>
b.initiatedName.localeCompare(a.initiatedName),
);
case "date-asc":
return sorted.sort((a, b) => {
if (!a.initiationDate && !b.initiationDate) return 0;
if (!a.initiationDate) return 1;
if (!b.initiationDate) return -1;
return a.initiationDate.localeCompare(b.initiationDate);
});
case "date-desc":
return sorted.sort((a, b) => {
if (!a.initiationDate && !b.initiationDate) return 0;
if (!a.initiationDate) return 1;
if (!b.initiationDate) return -1;
return b.initiationDate.localeCompare(a.initiationDate);
});
case "place":
return sorted.sort((a, b) => {
if (!a.initiationPlace && !b.initiationPlace) return 0;
if (!a.initiationPlace) return 1;
if (!b.initiationPlace) return -1;
const cmp = a.initiationPlace.localeCompare(b.initiationPlace);
return cmp !== 0 ? cmp : a.initiatedName.localeCompare(b.initiatedName);
});
}
}
// ---------------------------------------------------------------------------
// Sort Popover
// ---------------------------------------------------------------------------
function SortPopover({
sort,
onSortChange,
}: {
sort: SortMode;
onSortChange: (s: SortMode) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className={cn(
"flex items-center justify-center size-10 rounded-lg border transition-colors",
open
? "bg-primary/10 border-primary/30 text-primary"
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title="Sort options"
aria-label="Sort options"
>
<ArrowUpDown className="size-4" />
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-52 rounded-lg border border-border bg-card shadow-lg py-1 animate-in fade-in-0 zoom-in-95">
{SORT_OPTIONS.map((opt) => {
const Icon = opt.icon;
const isActive = sort === opt.key;
return (
<button
key={opt.key}
onClick={() => {
onSortChange(opt.key);
setOpen(false);
}}
className={cn(
"flex items-center gap-3 w-full px-3 py-2.5 text-sm transition-colors text-left",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-foreground hover:bg-muted",
)}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1">{opt.label}</span>
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center gap-3 pt-4">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
page <= 1
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronLeft className="size-4" />
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
page >= totalPages
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
Next
<ChevronRight className="size-4" />
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DisciplesBrowser({
initialDisciples,
initialLetter,
activeSuffix,
letterCounts,
suffixCounts,
totalCount,
filteredCount,
}: Props) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("name-asc");
const [page, setPage] = useState(1);
// Reset page when search changes
const handleQueryChange = (q: string) => {
setQuery(q);
setPage(1);
};
const handleSortChange = (s: SortMode) => {
setSort(s);
setPage(1);
};
// Build a lookup map for letter counts
const countByLetter = useMemo(() => {
const map = new Map<string, number>();
for (const lc of letterCounts) {
map.set(lc.letter.toUpperCase(), lc.count);
}
return map;
}, [letterCounts]);
// Build a lookup map for suffix counts
const countBySuffix = useMemo(() => {
const map = new Map<string, number>();
let total = 0;
for (const sc of suffixCounts) {
map.set(sc.suffix, sc.count);
total += sc.count;
}
map.set("all", total);
return map;
}, [suffixCounts]);
// Client-side search filtering + sorting
const filtered = useMemo(() => {
let data = initialDisciples;
if (query.trim()) {
const q = normalize(query.trim());
data = data.filter((d) => normalize(d.initiatedName).includes(q));
}
return sortDisciples(data, sort);
}, [initialDisciples, query, sort]);
// Pagination
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const startIdx = (safePage - 1) * PAGE_SIZE;
const pageData = filtered.slice(startIdx, startIdx + PAGE_SIZE);
// Total for letter display
const totalLetterCount = Array.from(countByLetter.values()).reduce(
(a, b) => a + b,
0,
);
return (
<div className="space-y-5">
{/* ---- Suffix Filter Tabs ---- */}
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-1 w-fit">
{SUFFIX_TABS.map((tab) => {
const count = countBySuffix.get(tab.key) ?? 0;
const isActive = activeSuffix === tab.key;
return (
<Link
key={tab.key}
href={buildHref(initialLetter, tab.key)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
<span
className={cn(
"text-xs tabular-nums",
isActive ? "text-foreground/60" : "text-muted-foreground/60",
)}
>
{count.toLocaleString()}
</span>
</Link>
);
})}
</div>
{/* ---- Letter Navigation Bar ---- */}
<nav className="flex flex-wrap gap-1 justify-center">
{/* "All" button */}
<Link
href={buildHref("All", activeSuffix)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg px-3 py-1.5 text-sm font-medium transition-colors min-w-9",
initialLetter === "All"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="leading-none">All</span>
<span
className={cn(
"text-[10px] leading-none mt-0.5",
initialLetter === "All"
? "text-primary-foreground/70"
: "text-muted-foreground/60",
)}
>
{totalLetterCount.toLocaleString()}
</span>
</Link>
{/* Divider */}
<div className="w-px bg-border self-stretch mx-0.5" />
{/* Letter buttons */}
{LETTERS.map((letter) => {
const count = countByLetter.get(letter) ?? 0;
const isActive =
letter.toUpperCase() === initialLetter.toUpperCase() &&
initialLetter !== "All";
const isEmpty = count === 0;
return (
<Link
key={letter}
href={buildHref(letter, activeSuffix)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors min-w-9",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: isEmpty
? "text-muted-foreground/40 cursor-default"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="leading-none">{letter}</span>
<span
className={cn(
"text-[10px] leading-none mt-0.5",
isActive
? "text-primary-foreground/70"
: "text-muted-foreground/60",
)}
>
{count}
</span>
</Link>
);
})}
</nav>
{/* ---- Search Bar + Sort ---- */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search initiated names..."
value={query}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleQueryChange(e.target.value)
}
className="pl-9"
/>
</div>
<SortPopover sort={sort} onSortChange={handleSortChange} />
</div>
{/* ---- Summary Stats ---- */}
<p className="text-sm text-muted-foreground">
{query.trim() ? (
<>
Found{" "}
<span className="font-medium text-foreground">
{filtered.length}
</span>{" "}
of{" "}
<span className="font-medium text-foreground">
{initialDisciples.length}
</span>{" "}
disciples
</>
) : (
<>
Showing{" "}
<span className="font-medium text-foreground">
{filteredCount.toLocaleString()}
</span>{" "}
{filteredCount === 1 ? "disciple" : "disciples"}
{filteredCount !== totalCount && (
<>
{" "}
of{" "}
<span className="font-medium text-foreground">
{totalCount.toLocaleString()}
</span>{" "}
total
</>
)}
</>
)}
</p>
{/* ---- Results Grid ---- */}
{pageData.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">No disciples found</p>
<p className="text-sm mt-1">
{query.trim()
? "Try a different search term"
: `No disciples found for the current filter`}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{pageData.map((disciple) => {
const locationYear = formatLocationYear(
disciple.initiationPlace,
disciple.initiationDate,
);
return (
<Card
key={disciple.id}
size="sm"
className="transition-all hover:shadow-md hover:ring-foreground/20"
>
<CardContent className="py-3 px-4 space-y-1.5">
<div className="flex items-start justify-between gap-2">
<p className="font-semibold text-foreground leading-snug truncate">
{disciple.initiatedName}
</p>
{disciple.suffix && (
<Badge
variant="secondary"
className="shrink-0 text-[10px]"
>
{disciple.suffix}
</Badge>
)}
</div>
{locationYear && (
<p className="text-xs text-muted-foreground truncate">
{locationYear}
</p>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* ---- Pagination ---- */}
<Pagination
page={safePage}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { ogForDisciples } from "@/lib/og";
import {
getTotalDiscipleCount,
getFilteredDisciples,
getDiscipleLetterCounts,
getDisciplePlaces,
getDiscipleYears,
getDiscipleSuffixCounts,
getFilteredDiscipleCount,
} from "@/lib/disciples";
import type { SuffixFilter } from "@/lib/disciples";
import { DisciplesBrowser } from "./disciples-browser";
export async function generateMetadata(): Promise<Metadata> {
return ogForDisciples();
}
const VALID_SUFFIXES: SuffixFilter[] = ["all", "Dasa", "Devi Dasi"];
function parseSuffix(raw: string | undefined): SuffixFilter {
if (!raw) return "all";
// Handle URL-encoded "Devi Dasi" → "Devi+Dasi" or "Devi%20Dasi"
const decoded = decodeURIComponent(raw.replace(/\+/g, " "));
if (VALID_SUFFIXES.includes(decoded as SuffixFilter)) {
return decoded as SuffixFilter;
}
return "all";
}
function parseLetter(raw: string | undefined): string {
if (!raw) return "A";
const cleaned = raw.trim();
if (cleaned.toLowerCase() === "all") return "All";
// Single uppercase letter
const ch = cleaned.charAt(0).toUpperCase();
if (ch >= "A" && ch <= "Z") return ch;
return "A";
}
export default async function DisciplesPage({
searchParams,
}: {
searchParams: Promise<{ letter?: string; suffix?: string }>;
}) {
const params = await searchParams;
const activeLetter = parseLetter(params.letter);
const activeSuffix = parseSuffix(params.suffix);
// letter=null means "All" — no letter filter
const letterFilter = activeLetter === "All" ? null : activeLetter;
const [
totalCount,
disciples,
filteredCount,
letterCounts,
places,
years,
suffixCounts,
] = await Promise.all([
getTotalDiscipleCount(),
getFilteredDisciples(letterFilter, activeSuffix),
getFilteredDiscipleCount(letterFilter, activeSuffix),
getDiscipleLetterCounts(),
getDisciplePlaces(),
getDiscipleYears(),
getDiscipleSuffixCounts(),
]);
// Serialize dates to ISO strings for the client component
const serializedDisciples = disciples.map((d) => ({
...d,
initiationDate: d.initiationDate ? d.initiationDate.toISOString() : null,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Disciples</h1>
<p className="mt-1 text-muted-foreground">
{totalCount.toLocaleString()} disciples initiated by Śrīla Prabhupāda
(1966–1977)
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<DisciplesBrowser
initialDisciples={serializedDisciples}
initialLetter={activeLetter}
activeSuffix={activeSuffix}
letterCounts={letterCounts}
suffixCounts={suffixCounts}
places={places}
years={years}
totalCount={totalCount}
filteredCount={filteredCount}
/>
</main>
</div>
);
}
+291
View File
@@ -0,0 +1,291 @@
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/layout/page-header";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
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/layout/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">
<JsonLd data={articleJsonLd({ path: `/essays/${slug}`, headline: essay.title, description: essay.paragraphs?.[0], isPartOf: "Essays & Talks" })} />
<PageHeader sticky>
<Link
href="/essays"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Essays
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
{essay.title}
</h1>
</PageHeader>
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<EssaySidebar items={allEssays} currentSlug={slug} />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<EssayNav
prev={prev}
next={next}
allEssays={allEssays}
currentSlug={slug}
/>
{/* Content */}
<Card>
<CardContent className="py-8 px-6 sm:px-8">
<div className="space-y-4">
{essay.paragraphs.map((paragraph, i) => (
<div
key={i}
className="text-base text-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: paragraph }}
/>
))}
</div>
</CardContent>
</Card>
{/* Bottom navigation */}
<EssayNav
prev={prev}
next={next}
allEssays={allEssays}
currentSlug={slug}
/>
</main>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function EssaySidebar({
items,
currentSlug,
}: {
items: SidebarItem[];
currentSlug: string;
}) {
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Essays
</h2>
<nav className="flex flex-col gap-0.5">
{items.map((item) => (
<Link
key={item.slug}
href={`/essays/${item.slug}`}
className={cn(
"px-2 py-1.5 rounded-md text-sm transition-colors truncate",
item.slug === currentSlug
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title={item.title}
>
{item.title}
</Link>
))}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// Prev / Next navigation
// ---------------------------------------------------------------------------
function EssayNav({
prev,
next,
allEssays,
currentSlug,
}: {
prev: SidebarItem | null;
next: SidebarItem | null;
allEssays: SidebarItem[];
currentSlug: string;
}) {
const mobileNavGroups: MobileNavGroup[] = [];
if (allEssays.length > 0) {
mobileNavGroups.push({
title: "Essays",
items: allEssays.map((i) => ({
slug: i.slug,
label: i.title,
href: `/essays/${i.slug}`,
type: "chapter",
})),
});
}
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MobileNav
groups={mobileNavGroups}
currentSlug={currentSlug}
bookTitle="Essays"
/>
{prev ? (
<Link
href={`/essays/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.title}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<Link
href={`/essays/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.title}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
export type EssayInfo = {
slug: string;
title: string;
year: number | null;
group: string | null;
href: string;
};
/**
* Strip diacritics / accents so that "Krsna" matches "Kṛṣṇa",
* "Gita" matches "Gītā", "Srimad" matches "Śrīmad", etc.
*/
function normalize(text: string): string {
return (
text
.toLowerCase()
.replace(/ṛ/g, "r")
.replace(/ṝ/g, "r")
.replace(/ṣ/g, "s")
.replace(/ś/g, "s")
.replace(/ṭ/g, "t")
.replace(/ḍ/g, "d")
.replace(/ṇ/g, "n")
.replace(/ñ/g, "n")
.replace(/ṅ/g, "n")
.replace(/ḥ/g, "h")
.replace(/ṁ/g, "m")
.replace(/ṃ/g, "m")
.replace(/ḷ/g, "l")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
);
}
/** Human-readable group labels */
const GROUP_LABELS: Record<string, string> = {
"cc-essays": "Śrī Caitanya-caritāmṛta Essays",
"pre-1977-essays": "Pre-1977 Essays",
"two-essays": "Two Essays (1966)",
reviews: "Reviews",
standalone: "Standalone",
"typed-transcripts": "Typed Transcripts",
};
function groupLabel(group: string | null): string {
if (!group) return "Other";
return GROUP_LABELS[group] ?? group;
}
export function EssaysGrid({ essays }: { essays: EssayInfo[] }) {
const [query, setQuery] = useState("");
const normalizedEssays = useMemo(
() =>
essays.map((essay) => ({
essay,
searchText: normalize(
`${essay.title} ${essay.group ?? ""} ${essay.slug}`,
),
})),
[essays],
);
const filtered = useMemo(() => {
if (!query.trim()) return essays;
const q = normalize(query.trim());
return normalizedEssays
.filter((entry) => entry.searchText.includes(q))
.map((entry) => entry.essay);
}, [essays, normalizedEssays, query]);
return (
<div className="space-y-6">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search essays by title..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Results count when searching */}
{query.trim() && (
<p className="text-sm text-muted-foreground">
{filtered.length === 0
? "No essays found"
: `${filtered.length} essay${filtered.length === 1 ? "" : "s"} found`}
</p>
)}
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((essay) => (
<Link key={essay.slug} href={essay.href} className="block h-full">
<Card
className={cn(
"h-full transition-all hover:shadow-md hover:ring-foreground/20",
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold leading-snug">
{essay.title}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{essay.year && (
<Badge variant="secondary" className="text-xs">
{essay.year}
</Badge>
)}
{essay.group && (
<span className="text-muted-foreground text-xs">
{groupLabel(essay.group)}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/layout/page-header";
import { buildOgMeta } from "@/lib/og";
import { bffList } from "@/lib/bff";
import { EssaysGrid } from "./essays-grid";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Essays",
detail: "Standalone essays by Śrīla Prabhupāda",
quote:
"Foundational essays on the science of Kṛṣṇa consciousness by His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda.",
});
}
async function getAllEssays() {
return bffList<{
slug: string;
title: string;
year: number | null;
group: string | null;
}>("/essays");
}
export default async function EssaysPage() {
const essays = await getAllEssays();
const items = essays.map((essay) => ({
slug: essay.slug,
title: essay.title,
year: essay.year,
group: essay.group,
href: `/essays/${essay.slug}`,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Essays</h1>
<p className="mt-1 text-muted-foreground">
Standalone essays by Śrīla Prabhupāda
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<EssaysGrid essays={items} />
</main>
</div>
);
}
@@ -0,0 +1,165 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAllGlossaryEntries } from "@/lib/api";
import { buildOgMeta, extractQuoteFromHl } from "@/lib/og";
import { GlossaryClient } from "../glossary-client";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
// ---------------------------------------------------------------------------
// Diacritic → base letter mapping
// ---------------------------------------------------------------------------
const DIACRITIC_TO_BASE: Record<string, string> = {
Ā: "A",
Ä: "A",
Ī: "I",
É: "I",
Ū: "U",
Ü: "U",
Ṛ: "R",
Ṝ: "R",
Ś: "S",
Ç: "S",
Ṣ: "S",
Ñ: "N",
Ṇ: "N",
Ṅ: "N",
Ṭ: "T",
Ḍ: "D",
Ḥ: "H",
};
function getBaseLetter(term: string): string {
if (!term) return "?";
const first = term[0].toUpperCase();
return DIACRITIC_TO_BASE[first] || first;
}
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ letter: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { letter } = await params;
const sp = await searchParams;
const l = letter.toUpperCase();
// If a specific term is referenced via ?term= param, show it in the OG image
const termParam = typeof sp.term === "string" ? sp.term : null;
if (termParam) {
const allEntries = await getAllGlossaryEntries();
const entry = allEntries.find(
(e) => e.term.toLowerCase() === termParam.toLowerCase(),
);
if (entry) {
// Strip HTML tags from definition for OG quote
const plainDef = entry.definitionHtml
.replace(/<[^>]*>/g, "")
.replace(/&[^;]+;/g, " ")
.trim();
// Check for ?hl= highlight param — extract actual selected text from definition
const hlQuote = extractQuoteFromHl(sp, [entry.definitionHtml]);
return buildOgMeta({
title: entry.term,
detail: "Glossary — Prabhupāda Says",
quote:
hlQuote ||
(plainDef.length > 260 ? plainDef.slice(0, 257) + "..." : plainDef),
});
}
}
// Check for ?hl= without ?term= — try to extract quote from all entries for this letter
const hlQuote = extractQuoteFromHl(sp);
if (hlQuote) {
return buildOgMeta({
title: `Glossary — ${l}`,
detail: "Sanskrit & Vedic terms",
quote: hlQuote,
});
}
// Default: letter-level OG
return buildOgMeta({
title: `Glossary — ${l}`,
detail: `Sanskrit & Vedic terms starting with ${l}`,
quote:
"A comprehensive glossary of Sanskrit and Vedic terms from the books of Śrīla Prabhupāda.",
});
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function GlossaryLetterPage({
params,
}: {
params: Promise<{ letter: string }>;
}) {
const { letter: rawLetter } = await params;
const activeLetter = rawLetter.toUpperCase();
// Validate — single letter A-Z
if (!/^[A-Z]$/.test(activeLetter)) notFound();
// Fetch all entries (cached by Next.js request dedup within a single render)
const allEntries = await getAllGlossaryEntries();
// Group all entries by base letter
const byLetter: Record<string, typeof allEntries> = {};
for (const entry of allEntries) {
const base = getBaseLetter(entry.term);
if (!byLetter[base]) byLetter[base] = [];
byLetter[base].push(entry);
}
// All available letters (sorted)
const letters = Object.keys(byLetter).sort();
// Entries for the active letter
const letterEntries = byLetter[activeLetter] || [];
if (letterEntries.length === 0) notFound();
// Prepare client data — only the active letter's entries
const clientEntries = letterEntries.map((e) => ({
term: e.term,
definition: e.definition,
definition_html: e.definitionHtml,
}));
// Letter counts for the nav
const letterCounts: Record<string, number> = {};
for (const [l, entries] of Object.entries(byLetter)) {
letterCounts[l] = entries.length;
}
return (
<div className="min-h-screen bg-background">
<JsonLd
data={articleJsonLd({
path: `/glossary/${rawLetter}`,
headline: `Glossary — ${activeLetter}`,
isPartOf: "Glossary of Sanskrit & Vedic Terms",
})}
/>
<GlossaryClient
entries={clientEntries}
activeLetter={activeLetter}
letters={letters}
letterCounts={letterCounts}
/>
</div>
);
}

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