Author SHA1 Message Date
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
120 changed files with 6110 additions and 23860 deletions
+257 -212
View File
@@ -15,10 +15,10 @@ Content is sourced from the pre-1980s Vedabase, extracted and indexed into Mongo
- **shadcn/ui** (base-nova style, lucide icons) — components: Button, Card, Badge, Tooltip, Input, Drawer - **shadcn/ui** (base-nova style, lucide icons) — components: Button, Card, Badge, Tooltip, Input, Drawer
- **next-themes** for dark/light/system mode - **next-themes** for dark/light/system mode
- **vaul** for mobile drawer navigation - **vaul** for mobile drawer navigation
- **Prisma v6** with MongoDB (provider `prisma-client-js`) - **Go BFF API** — all data fetched via `lib/bff.ts` HTTP client calling the Go BFF server (no direct database access)
- **MongoDB** native driver (`mongodb` package) for seeding (Prisma requires replica set for writes) - **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 - **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
- **Database**: MongoDB at `DATABASE_URL` env variable with `?authSource=admin`
--- ---
@@ -64,10 +64,19 @@ knowledgebase/
│ ├── disciples/ → 4,747 initiated disciples directory │ ├── disciples/ → 4,747 initiated disciples directory
│ │ ├── page.tsx → Disciples browser (letter nav, suffix filter, search, sort, pagination) │ │ ├── page.tsx → Disciples browser (letter nav, suffix filter, search, sort, pagination)
│ │ └── disciples-browser.tsx → Client component │ │ └── disciples-browser.tsx → Client component
│ ├── transcriptions/page.tsx → Coming Soon page │ ├── 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/ │ └── api/
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image) │ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
│ ├── books/ → Book/verse API routes │ ├── books/ → Book/verse API routes
│ ├── search/route.ts → BFF search proxy (Typesense via /api/v1/search)
│ └── glossary/route.ts → Glossary API │ └── glossary/route.ts → Glossary API
├── components/ ├── components/
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer) │ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer)
@@ -82,26 +91,31 @@ knowledgebase/
│ ├── page-header.tsx → Shared header with ReadingSettings (max-w-5xl) │ ├── page-header.tsx → Shared header with ReadingSettings (max-w-5xl)
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel │ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation │ ├── 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) │ ├── 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 │ ├── logo.tsx → Theme-aware logo component
│ └── theme-provider.tsx → next-themes wrapper │ └── theme-provider.tsx → next-themes wrapper
├── lib/ ├── lib/
│ ├── db.ts → Prisma client singleton │ ├── bff.ts → BFF API client (typed fetch wrapper: bff<T>(), bffList<T>(), bffOrThrow<T>())
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary) │ ├── 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) │ ├── constants.ts → Shared constants (BOOK_NAMES, CC_LILAS)
│ ├── og.ts → OG image metadata builders (ogForVerse, ogForLetter, etc.) │ ├── og.ts → OG image metadata builders (ogForVerse, ogForLetter, etc.)
│ ├── text-highlight.ts → Text fragment encode/decode, highlight matching │ ├── text-highlight.ts → Text fragment encode/decode, highlight matching
│ ├── source-reference.ts → Source reference from URL (for copy attribution) │ ├── source-reference.ts → Source reference from URL (for copy attribution)
│ ├── disciples.ts → Disciples data access (by letter, place, year, search) │ ├── disciples.ts → Disciples data access — calls BFF API
│ ├── letters.ts → Letters data access (by year, recipient, location) │ ├── letters.ts → Letters data access (by year, recipient, location) — calls BFF API
│ ├── transcriptions.ts → Transcriptions data access — calls BFF API
│ ├── utils.ts → cn() helper │ ├── utils.ts → cn() helper
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.) │ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.) — calls BFF API
│ ├── other-documents.ts → Other Documents data access │ ├── other-documents.ts → Other Documents data access — calls BFF API
│ ├── journals.ts → Journals data access │ ├── journals.ts → Journals data access — calls BFF API
│ └── magazines.ts → Magazines data access │ └── magazines.ts → Magazines data access — calls BFF API
├── prisma/
│ └── schema.prisma → All models (Verse, BookMeta, ProseChapter, Essay, Letter,
│ GlossaryEntry, Disciple, Transcription, etc.)
└── public/ └── public/
├── fonts/Noto_Sans/static/ → NotoSans-Regular.ttf + NotoSans-Bold.ttf (OG images) ├── fonts/Noto_Sans/static/ → NotoSans-Regular.ttf + NotoSans-Bold.ttf (OG images)
├── images/ → OG card image (Prabhupāda) ├── images/ → OG card image (Prabhupāda)
@@ -121,199 +135,50 @@ extractor/ → Extraction & seeding tools (not deployed)
--- ---
## Database Schema (Prisma + MongoDB) ## Data Architecture
```prisma The knowledgebase **never connects to MongoDB directly**. All data is served by the **Go BFF API** server.
type Synonym {
word String
meanings String[]
}
model Verse { ### BFF Server
id String @id @default(auto()) @map("_id") @db.ObjectId
book String // "bg" | "sb" | "cc" | "sb1962" | "iso" | "noi"
division String? // null for BG/SB1962/ISO/NOI; canto for SB; lila for CC
chapter Int
verse Int
verseEnd Int?
reference String
bookTitle String
devanagari String
bengali String? // only CC has Bengali
transliteration String
synonyms Synonym[]
translation String
purport String[]
@@unique([book, division, chapter, verse]) - **URL**: Configured via `BFF_URL` environment variable (default: `http://localhost:8080`)
@@index([book]) - The BFF reads from MongoDB — the knowledgebase frontend has no database driver or connection string
@@index([book, division]) - All API endpoints live under `/api/v1/...` on the BFF server
@@index([book, division, chapter])
@@map("verses")
}
model BookMeta { ### BFF Client (`lib/bff.ts`)
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("book_meta") The typed fetch wrapper provides three helpers:
}
model ProseChapter { - **`bff<T>(path, options?)`** — Fetch JSON from the BFF. Returns `null` on 404, throws `BffError` on other failures.
id String @id @default(auto()) @map("_id") @db.ObjectId - **`bffList<T>(path, options?)`** — Fetch a list. Returns `[]` on 404 (instead of null).
book String - **`bffOrThrow<T>(path, options?)`** — Fetch JSON, throwing on 404. Use when a missing resource should be an error.
volume Int // 0 = no volumes (sentinel for non-volume books)
chapter Int? // null for intro segments
title String
type String // "chapter" | "intro-segment" | "conclusion"
slug String
order Int
paragraphs String[]
@@unique([book, volume, slug]) Options include `params` (query parameters), `revalidate` (Next.js ISR seconds, default 60), and `tags` (cache tags).
@@index([book])
@@index([book, volume])
@@index([book, volume, type])
@@map("prose_chapters")
}
model Essay { ### Response Envelope
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
title String
author String
year Int?
group String? // e.g., "two-essays"
order Int
paragraphs String[]
@@index([group]) BFF responses use a `{ success: true, data: ... }` envelope. The `bff()` client auto-unwraps this — callers receive the inner `data` value directly.
@@map("essays")
}
model OtherDocumentMeta { ### Case Mapping
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("other_document_meta") 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.
}
model OtherDocumentChapter { ### Data Layer Files
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
chapter Int
title String
type String
slug String
order Int
paragraphs String[]
@@unique([book, slug]) Individual `lib/*.ts` files are thin wrappers that:
@@index([book]) 1. Call a BFF endpoint via `bff()` / `bffList()` / `bffOrThrow()`
@@map("other_document_chapters") 2. Map the snake_case response to camelCase frontend types
} 3. Export typed functions consumed by Next.js server components
model JournalMeta { Key data layers and their BFF endpoint prefixes:
id String @id @default(auto()) @map("_id") @db.ObjectId - `lib/api.ts` → `/api/v1/books/*`, `/api/v1/glossary/*` — Books, verses, prose chapters, glossary
book String @unique - `lib/books.ts` → `/api/v1/books/{code}/*` — Generic book data layer (replaces 29 individual per-book files)
data Json - `lib/letters.ts` → `/api/v1/letters/*` — Letters (by year, recipient, location)
- `lib/transcriptions.ts` → `/api/v1/transcriptions/*` — Transcriptions (by year, place, type)
@@map("journal_meta") - `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
model JournalChapter { - `lib/magazines.ts` → `/api/v1/magazines/*` — Magazines
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
chapter Int
title String
type String
slug String
order Int
paragraphs String[]
@@unique([book, slug])
@@index([book])
@@map("journal_chapters")
}
model MagazineMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("magazine_meta")
}
model MagazineIssue {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
volume Int
part Int
title String
slug String
order Int
paragraphs String[]
paragraphsJson String? // JSON string fallback for large issues (BSON wire protocol workaround)
@@unique([book, slug])
@@index([book])
@@index([book, volume])
@@map("magazine_issues")
}
model Letter {
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
recipients String[] // array of individual recipient names
recipientDisplay String // original combined string for display
date String?
dateRef String?
year Int?
month Int?
day Int?
location String?
salutation String?
paragraphs String[]
@@index([year])
@@index([recipients])
@@index([location])
@@index([year, month])
@@map("letters")
}
model GlossaryEntry {
id String @id @default(auto()) @map("_id") @db.ObjectId
term String
definition String
definitionHtml String
@@unique([term])
@@map("glossary")
}
model Disciple {
id String @id @default(auto()) @map("_id") @db.ObjectId
initiatedName String
suffix String // "Dasa" | "Devi Dasi" | "Sw"
initiationDate DateTime? // 1st of the month (exact day unknown unless verified)
initiationPlace String?
karmiName String? // pre-initiation (birth) name
avatar String? // URL to profile photo
about String? // biographical notes
tags String[]
isVerified Boolean @default(false)
@@index([initiatedName])
@@index([initiationPlace])
@@index([initiationDate])
@@index([suffix])
@@index([tags])
@@map("disciples")
}
```
--- ---
@@ -459,15 +324,29 @@ Seed script: `npx tsx scripts/seed-disciples.ts`
**Schema notes**: **Schema notes**:
- `recipients` is an array of individual names (e.g., `["Mukunda", "Janaki"]`) - `recipients` is an array of individual names (e.g., `["Mukunda", "Janaki"]`)
- `recipientDisplay` is the original combined string (e.g., `"Mukunda, Janaki"`) - `recipientDisplay` is the original combined string (e.g., `"Mukunda, Janaki"`)
- Searching for a recipient uses Prisma `{ has: name }` to match any letter where that person appears - Searching for a recipient filters via the BFF endpoint (matches any letter where that person appears)
- Multi-recipient groups are split by `,`, `;`, ` and `, ` & ` - Multi-recipient groups are split by `,`, `;`, ` and `, ` & `
- Trailing punctuation (`:`, `,`, `.`) cleaned from names - 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 ### Other
| Content | Collection | | Content | Collection |
|---------|-----------| |---------|-----------|
| Glossary (2,749 entries) | `glossary` | | Glossary (2,749 entries) | `glossary` |
| Transcriptions (3,787 entries) | `transcriptions` collection |
--- ---
@@ -582,6 +461,27 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
| `/letters/location/Los%20Angeles` | All letters from a specific location | | `/letters/location/Los%20Angeles` | All letters from a specific location |
| `/letters/[slug]` | Individual letter reader with prev/next navigation | | `/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 ### Other routes
| Route | Content | | Route | Content |
@@ -635,11 +535,17 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
### Reading Settings (accessibility) ### Reading Settings (accessibility)
- **Theme**: Light / Dark / System toggle - **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 - **Serif Font**: Toggle to switch reading content to Georgia/serif
- **Font Size**: Slider (XS → XXL) using CSS `zoom` on `main` and `aside` - **Font Size**: Slider (XS → XXL) using CSS `zoom` on `main` and `aside`
- **Justify Text**: Toggle for justified text alignment on reading content - **Justify Text**: Toggle for justified text alignment on reading content
- Settings persist in `localStorage` under `spsays_reading_prefs` - Settings persist in `localStorage` under `spsays_reading_prefs` (now includes `lightPalette` and `darkPalette` fields)
- Applied via CSS classes on `<html>` element - Applied via CSS classes (`reading-serif`, `reading-fs-*`, `reading-justified`) AND data attributes (`data-light-palette`, `data-dark-palette`) on `<html>` element
### Mobile Navigation ### Mobile Navigation
- Left-slide drawer (vaul) triggered by a menu button next to the Previous button - Left-slide drawer (vaul) triggered by a menu button next to the Previous button
@@ -647,14 +553,80 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
- Shows the full sidebar navigation grouped by section - Shows the full sidebar navigation grouped by section
- Implemented in all 24+ reader pages via shared `MobileNav` component - 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** (`/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") - Books page: diacritic-aware search (typing "Krsna" matches "Kṛṣṇa")
- Glossary page: full-text search across terms and definitions - Glossary page: full-text search across terms and definitions
- Uses Unicode NFD normalization + manual IAST character stripping - Uses Unicode NFD normalization + manual IAST character stripping
### Color Palette ### Color Palettes
- **Light mode ("Aged Parchment")**: Warm cream backgrounds, deep sepia text, saffron/ochre primary
- **Dark mode ("Ancient Manuscript")**: Deep warm brown backgrounds, parchment text, golden saffron primary 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 ### Text Selection Toolbar
- Global floating toolbar appears on text selection across all reading pages (except RTFs) - Global floating toolbar appears on text selection across all reading pages (except RTFs)
@@ -676,6 +648,8 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
- Server-side `extractMatchedText()` searches paragraphs for OG image quote extraction - Server-side `extractMatchedText()` searches paragraphs for OG image quote extraction
- Case-insensitive matching for both DOM highlighting and server-side text extraction - Case-insensitive matching for both DOM highlighting and server-side text extraction
- Cross-paragraph matching via position mapping (spaces inserted at element boundaries) - 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 ### Dynamic OG Images
- `/api/og` route generates 1200×630 PNG images using `next/og` (Satori) - `/api/og` route generates 1200×630 PNG images using `next/og` (Satori)
@@ -685,6 +659,31 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
- All 104 content pages have `generateMetadata` with contextual fallback descriptions - 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 - 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 ### Site Footer
- Global footer on every page with © year, About link, Home link - Global footer on every page with © year, About link, Home link
@@ -696,30 +695,32 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
## Environment Variables ## Environment Variables
``` ```
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin BFF_URL=http://localhost:8080 # Go BFF API server URL (default: http://localhost:8080)
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org 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 ## How to Run
```bash ```bash
# Development (from knowledgebase/) # 1. Start the Go BFF server (required — all data comes from the BFF)
npm run dev cd spsays-bff && go run cmd/api/main.go # Starts on :8080 by default
# Prisma # 2. Development (from knowledgebase/)
npx prisma generate # Regenerate Prisma client after schema changes npm run dev # Next.js dev server with Turbopack
# Build # 3. Build
npx next build npx next build
# Extraction & seeding scripts are in extractor/ — see extractor/scripts/ # Extraction & seeding scripts are in extractor/ — see extractor/scripts/
# BFF API server (from ../spsays-bff/)
make run # Build + start Go API on :8080
``` ```
> **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) ## Verse JSON Schema (BG/SB/CC/SB1962/ISO/NOI)
@@ -803,11 +804,47 @@ make run # Build + start Go API on :8080
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. 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 ## Known Issues
1. **MongoDB BSON wire corruption**: The MongoDB server occasionally corrupts large BSON documents. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time. 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. 2. **SB verse numbering**: 5 duplicate verse reference tags in the HTML. Fixed by using the TEXT N header as authoritative.
@@ -817,4 +854,12 @@ Glossary is browsed via `/glossary/{letter}` routes (A-Z). Each letter page show
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)`. 5. **CSS zoom for font scaling**: `zoom` property works in Chrome/Safari/Edge but may not work in older Firefox (< 126). A `transform: scale()` fallback is provided via `@supports not (zoom: 1.1)`.
6. **Password protection requires ACCESS_KEY at runtime**: The `PasswordGate` component checks the server at runtime via `/api/verify-access`. The env var must be available when the server is running, not just at build time. 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.
+32 -8
View File
@@ -4,16 +4,18 @@ import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og"; import { buildOgMeta } from "@/lib/og";
import { import {
BookOpen, BookOpen,
Users,
BrainCircuit, BrainCircuit,
Blocks, Blocks,
Circle, Circle,
CheckCircle2, CircleCheck,
Clock
} from "lucide-react"; } from "lucide-react";
const PHASES = [ const PHASES = [
{ {
title: "Phase I — The Vedabase", title: "Phase I — The Vedabase",
status: "in-progress" as const, status: "done" as const,
icon: BookOpen, icon: BookOpen,
description: description:
"Indexing the complete pre-1980s Vedabase into a structured, searchable database — books, letters, essays, magazines, journals, glossary, and disciple records — with a clean, accessible reading interface.", "Indexing the complete pre-1980s Vedabase into a structured, searchable database — books, letters, essays, magazines, journals, glossary, and disciple records — with a clean, accessible reading interface.",
@@ -26,7 +28,21 @@ const PHASES = [
], ],
}, },
{ {
title: "Phase II — AI Companion", title: "Phase II — Devotee Features",
status: "in-progress" as const,
icon: Users,
description:
"Adding user-centric features so devotees can engage with the archive personally — sign in, save favourite quotes, build collections, share with attribution, and tag someone to a specific text.",
highlights: [
"Account sign-in and personal profiles",
"Save and organise quotes into collections",
"Share quotes with rich attribution and highlight links",
"Bookmark verses, letters, and lectures for later reading",
"Mention on text — tag someone to look into your selected text",
],
},
{
title: "Phase III — AI Companion",
status: "upcoming" as const, status: "upcoming" as const,
icon: BrainCircuit, icon: BrainCircuit,
description: description:
@@ -38,7 +54,7 @@ const PHASES = [
], ],
}, },
{ {
title: "Phase III — Decentralized Preservation", title: "Phase IV — Decentralized Preservation",
status: "upcoming" as const, status: "upcoming" as const,
icon: Blocks, icon: Blocks,
description: description:
@@ -134,7 +150,7 @@ export default function AboutPage() {
<div> <div>
<h2 className="text-2xl font-bold text-foreground">Roadmap</h2> <h2 className="text-2xl font-bold text-foreground">Roadmap</h2>
<p className="mt-1 text-[15px] text-muted-foreground"> <p className="mt-1 text-[15px] text-muted-foreground">
Our work is organized into three phases — each building on the Our work is organized into four phases — each building on the
last. last.
</p> </p>
</div> </div>
@@ -145,13 +161,16 @@ export default function AboutPage() {
{PHASES.map((phase, i) => { {PHASES.map((phase, i) => {
const Icon = phase.icon; const Icon = phase.icon;
const isDone = phase.status === "done";
const isInProgress = phase.status === "in-progress"; const isInProgress = phase.status === "in-progress";
return ( return (
<div key={i} className="relative flex gap-5 pb-10 last:pb-0"> <div key={i} className="relative flex gap-5 pb-10 last:pb-0">
{/* Timeline node */} {/* Timeline node */}
<div className="relative z-10 flex items-center justify-center size-10 shrink-0 rounded-full bg-background ring-1 ring-border"> <div className="relative z-10 flex items-center justify-center size-10 shrink-0 rounded-full bg-background ring-1 ring-border">
{isInProgress ? ( {isDone ? (
<Icon className="size-4 text-primary" />
) : isInProgress ? (
<div className="relative"> <div className="relative">
<Icon className="size-4 text-primary" /> <Icon className="size-4 text-primary" />
<span className="absolute -top-1 -right-1 size-2.5 rounded-full bg-primary animate-pulse" /> <span className="absolute -top-1 -right-1 size-2.5 rounded-full bg-primary animate-pulse" />
@@ -167,9 +186,14 @@ export default function AboutPage() {
<h3 className="text-lg font-semibold text-foreground"> <h3 className="text-lg font-semibold text-foreground">
{phase.title} {phase.title}
</h3> </h3>
{isInProgress ? ( {isDone ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/15 px-2.5 py-0.5 text-xs font-medium text-primary">
<CircleCheck className="size-3" />
Complete
</span>
) : isInProgress ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary"> <span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
<CheckCircle2 className="size-3" /> <Clock className="size-3" />
In Progress In Progress
</span> </span>
) : ( ) : (
+4 -4
View File
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { getBookMeta } from "@/lib/api";
export async function GET( export async function GET(
_req: Request, _req: Request,
{ params }: { params: Promise<{ book: string }> } { params }: { params: Promise<{ book: string }> },
) { ) {
const { book } = await params; const { book } = await params;
const meta = await prisma.bookMeta.findUnique({ where: { book } }); const meta = await getBookMeta(book);
if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ book: meta.book, ...meta.data as Record<string, unknown> }); return NextResponse.json(meta);
} }
+21 -37
View File
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import {
getSingleVerse,
getChapterVerseIndex,
getChapterVerses,
} from "@/lib/api";
export async function GET( export async function GET(
req: Request, req: Request,
@@ -7,7 +11,7 @@ export async function GET(
) { ) {
const { book } = await params; const { book } = await params;
const url = new URL(req.url); const url = new URL(req.url);
const division = url.searchParams.get("division"); const division = url.searchParams.get("division") || null;
const chapterStr = url.searchParams.get("chapter"); const chapterStr = url.searchParams.get("chapter");
const verseStr = url.searchParams.get("verse"); const verseStr = url.searchParams.get("verse");
const indexOnly = url.searchParams.get("indexOnly") === "true"; const indexOnly = url.searchParams.get("indexOnly") === "true";
@@ -22,20 +26,18 @@ export async function GET(
} }
const chapter = parseInt(chapterStr); const chapter = parseInt(chapterStr);
const where: Record<string, unknown> = { book, chapter };
if (book === "bg") { const effectiveDivision = book === "bg" ? null : division;
where.division = null;
} else if (division) {
where.division = division;
}
// --- Single verse mode: ?verse=N --- // --- Single verse mode: ?verse=N ---
if (verseStr) { if (verseStr) {
const verseNum = parseInt(verseStr); const verseNum = parseInt(verseStr);
const verse = await prisma.verse.findFirst({ const verse = await getSingleVerse(
where: { ...where, verse: verseNum }, book,
}); effectiveDivision,
chapter,
verseNum,
);
if (!verse) { if (!verse) {
return NextResponse.json({ error: "Verse not found" }, { status: 404 }); return NextResponse.json({ error: "Verse not found" }, { status: 404 });
@@ -46,15 +48,7 @@ export async function GET(
// --- Index-only mode: return lightweight verse list for the sidebar --- // --- Index-only mode: return lightweight verse list for the sidebar ---
if (indexOnly) { if (indexOnly) {
const verses = await prisma.verse.findMany({ const verses = await getChapterVerseIndex(book, effectiveDivision, chapter);
where,
orderBy: { verse: "asc" },
select: {
verse: true,
verseEnd: true,
reference: true,
},
});
return NextResponse.json({ verses, total: verses.length }); return NextResponse.json({ verses, total: verses.length });
} }
@@ -62,24 +56,14 @@ export async function GET(
// --- Paginated mode: return full verse data in pages --- // --- Paginated mode: return full verse data in pages ---
const page = Math.max(1, parseInt(pageStr || "1")); const page = Math.max(1, parseInt(pageStr || "1"));
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20"))); const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
const skip = (page - 1) * limit;
const [verses, total] = await Promise.all([ const result = await getChapterVerses(
prisma.verse.findMany({ book,
where, effectiveDivision,
orderBy: { verse: "asc" }, chapter,
skip,
take: limit,
}),
prisma.verse.count({ where }),
]);
return NextResponse.json({
verses,
total,
page, page,
limit, limit,
totalPages: Math.ceil(total / limit), );
hasMore: skip + verses.length < total,
}); return NextResponse.json(result);
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { getAllBooks } from "@/lib/api";
export async function GET() { export async function GET() {
const metas = await prisma.bookMeta.findMany(); const books = await getAllBooks();
return NextResponse.json(metas.map((m) => ({ book: m.book, ...m.data as Record<string, unknown> }))); return NextResponse.json(books);
} }
+2 -4
View File
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { getAllGlossaryEntries } from "@/lib/api";
export async function GET() { export async function GET() {
const entries = await prisma.glossaryEntry.findMany({ const entries = await getAllGlossaryEntries();
orderBy: { term: "asc" },
});
return NextResponse.json(entries); return NextResponse.json(entries);
} }
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
const BFF_BASE = process.env.BFF_URL || "http://localhost:8080";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const q = searchParams.get("q") || "";
const type = searchParams.get("type") || "all";
const page = searchParams.get("page") || "1";
const limit = searchParams.get("limit") || "20";
if (!q.trim()) {
return NextResponse.json({
query: "",
type,
results: [],
total: 0,
page: 1,
limit: 20,
total_pages: 0,
});
}
try {
const url = new URL("/api/v1/search", BFF_BASE);
url.searchParams.set("q", q);
url.searchParams.set("type", type);
url.searchParams.set("page", page);
url.searchParams.set("limit", limit);
const res = await fetch(url.toString(), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
return NextResponse.json(
{
query: q,
type,
results: [],
total: 0,
page: Number(page),
limit: Number(limit),
total_pages: 0,
},
{ status: res.status },
);
}
const data = await res.json();
// Unwrap BFF envelope: { data: { ... } } → { ... }
const payload = data.data ?? data;
return NextResponse.json(payload);
} catch {
return NextResponse.json(
{
query: q,
type,
results: [],
total: 0,
page: Number(page),
limit: Number(limit),
total_pages: 0,
},
{ status: 502 },
);
}
}
-1
View File
@@ -60,7 +60,6 @@ export default async function BbdPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+1 -8
View File
@@ -162,14 +162,7 @@ function SidebarLink({
item: BkSidebarItem; item: BkSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
+1 -1
View File
@@ -86,7 +86,7 @@ export function BooksGrid({ books }: { books: BookInfo[] }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder="Search books..." placeholder="Search books by name..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9" className="pl-9"
-2
View File
@@ -118,7 +118,6 @@ export default async function BsPage() {
{introduction.title} {introduction.title}
</p> </p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -133,7 +132,6 @@ export default async function BsPage() {
{foreword.title} {foreword.title}
</p> </p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{foreword.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+1 -8
View File
@@ -162,14 +162,7 @@ function SidebarLink({
item: CbhcSidebarItem; item: CbhcSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
-1
View File
@@ -63,7 +63,6 @@ export default async function DsPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -63,7 +63,6 @@ export default async function EjPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function EkPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+1 -8
View File
@@ -217,14 +217,7 @@ function SidebarLink({
item: GgSidebarItem; item: GgSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
-2
View File
@@ -64,7 +64,6 @@ export default async function GgPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -126,7 +125,6 @@ export default async function GgPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function HkhPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+7 -4
View File
@@ -50,8 +50,11 @@ export async function generateMetadata({
const isVerse_ = content && "mantra" in content && "devanagari" in content; const isVerse_ = content && "mantra" in content && "devanagari" in content;
const paragraphs = content const paragraphs = content
? isVerse_ ? isVerse_
? [(content as IsoVerse).translation, ...(content as IsoVerse).purport] ? [
: (content as IsoIntroSegment).paragraphs (content as unknown as IsoVerse).translation,
...(content as unknown as IsoVerse).purport,
]
: (content as unknown as IsoIntroSegment).paragraphs
: undefined; : undefined;
const quote = extractQuoteFromHl(sp, paragraphs); const quote = extractQuoteFromHl(sp, paragraphs);
@@ -64,7 +67,7 @@ export async function generateMetadata({
} }
const title = isVerse_ const title = isVerse_
? `${(content as IsoVerse).reference}: ${(content as IsoVerse).title}` ? `${(content as unknown as IsoVerse).reference}`
: content && "title" in content : content && "title" in content
? (content as IsoIntroSegment).title ? (content as IsoIntroSegment).title
: `Mantra ${slug}`; : `Mantra ${slug}`;
@@ -110,7 +113,7 @@ export default async function IsoMantraPage({
{verse && ( {verse && (
<> <>
<span className="text-primary">{verse.reference}: </span> <span className="text-primary">{verse.reference}: </span>
{verse.title} {verse.bookTitle}
</> </>
)} )}
{intro && intro.title} {intro && intro.title}
-3
View File
@@ -68,7 +68,6 @@ export default async function IsoPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{frontPages.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -85,7 +84,6 @@ export default async function IsoPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -164,7 +162,6 @@ export default async function IsoPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{aboutAuthor.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-2
View File
@@ -72,7 +72,6 @@ export default async function KbVolumePage({
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -106,7 +105,6 @@ export default async function KbVolumePage({
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{ch.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+1 -8
View File
@@ -221,14 +221,7 @@ function SidebarLink({
item: LclSidebarItem; item: LclSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
-2
View File
@@ -64,7 +64,6 @@ export default async function LclPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -126,7 +125,6 @@ export default async function LclPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function LobPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function MgPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -116,7 +116,6 @@ export default async function MmPage() {
{introduction.title} {introduction.title}
</p> </p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function MogPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -116,7 +116,6 @@ export default async function NbsPage() {
{introduction.title} {introduction.title}
</p> </p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+1 -8
View File
@@ -203,14 +203,7 @@ function SidebarLink({
item: NodSidebarItem; item: NodSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
-1
View File
@@ -70,7 +70,6 @@ export default async function NodPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-3
View File
@@ -65,7 +65,6 @@ export default async function NoiPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{frontPages.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -82,7 +81,6 @@ export default async function NoiPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{preface.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -141,7 +139,6 @@ export default async function NoiPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{appendixes.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function OchkmPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function OwkPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function PoyPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+1 -8
View File
@@ -239,14 +239,7 @@ function SidebarLink({
item: PqpaSidebarItem; item: PqpaSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
-2
View File
@@ -64,7 +64,6 @@ export default async function PqpaPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -151,7 +150,6 @@ export default async function PqpaPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-1
View File
@@ -60,7 +60,6 @@ export default async function RvPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-2
View File
@@ -64,7 +64,6 @@ export default async function SsrPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -123,7 +122,6 @@ export default async function SsrPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
-2
View File
@@ -64,7 +64,6 @@ export default async function SvaPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -123,7 +122,6 @@ export default async function SvaPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+45 -10
View File
@@ -141,6 +141,31 @@ function TlcSidebar({
const introItems = items.filter((item) => item.type === "intro-segment"); const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter"); 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 ( 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"> <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 */} {/* Front Matter */}
@@ -161,8 +186,25 @@ function TlcSidebar({
</> </>
)} )}
{/* Chapters */} {/* Chapters — grouped by part if available */}
{chapterItems.length > 0 && ( {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"> <h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters Chapters
@@ -189,14 +231,7 @@ function SidebarLink({
item: TlcSidebarItem; item: TlcSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
-1
View File
@@ -68,7 +68,6 @@ export default async function TlcPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+1 -8
View File
@@ -226,14 +226,7 @@ function SidebarLink({
item: TlkSidebarItem; item: TlkSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
-2
View File
@@ -64,7 +64,6 @@ export default async function TlkPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -126,7 +125,6 @@ export default async function TlkPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+1 -8
View File
@@ -226,14 +226,7 @@ function SidebarLink({
item: TqkSidebarItem; item: TqkSidebarItem;
isActive: boolean; isActive: boolean;
}) { }) {
let label: string; const label = item.label;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
return ( return (
<Link <Link
-2
View File
@@ -64,7 +64,6 @@ export default async function TqkPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
@@ -126,7 +125,6 @@ export default async function TqkPage() {
</p> </p>
</div> </div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4"> <span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span> </span>
</Link> </Link>
</li> </li>
+11 -27
View File
@@ -10,8 +10,7 @@ import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav"; import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
import { prisma } from "@/lib/db"; import { bff, bffList } from "@/lib/bff";
import type { JsonValue } from "@prisma/client/runtime/library";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// HTML sanitisation (same pattern used across all readers) // HTML sanitisation (same pattern used across all readers)
@@ -31,25 +30,6 @@ function sanitizeHtml(html: string): string {
return clean; return clean;
} }
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data fetching // Data fetching
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -69,7 +49,14 @@ type SidebarItem = {
}; };
async function getEssay(slug: string): Promise<EssayData | null> { async function getEssay(slug: string): Promise<EssayData | null> {
const doc = await prisma.essay.findUnique({ where: { slug } }); const doc = await bff<{
slug: string;
title: string;
author: string;
year: number | null;
group: string | null;
paragraphs: string[];
}>(`/essays/${slug}`);
if (!doc) return null; if (!doc) return null;
return { return {
slug: doc.slug, slug: doc.slug,
@@ -77,15 +64,12 @@ async function getEssay(slug: string): Promise<EssayData | null> {
author: doc.author, author: doc.author,
year: doc.year, year: doc.year,
group: doc.group, group: doc.group,
paragraphs: normalizeParagraphs(doc.paragraphs), paragraphs: (doc.paragraphs ?? []).map(sanitizeHtml),
}; };
} }
async function getEssaySidebar(): Promise<SidebarItem[]> { async function getEssaySidebar(): Promise<SidebarItem[]> {
return prisma.essay.findMany({ return bffList<SidebarItem>("/essays");
orderBy: [{ group: "asc" }, { order: "asc" }],
select: { slug: true, title: true },
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -1
View File
@@ -91,7 +91,7 @@ export function EssaysGrid({ essays }: { essays: EssayInfo[] }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder="Search essays..." placeholder="Search essays by title..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9" className="pl-9"
+7 -5
View File
@@ -4,7 +4,7 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og"; import { buildOgMeta } from "@/lib/og";
import { prisma } from "@/lib/db"; import { bffList } from "@/lib/bff";
import { EssaysGrid } from "./essays-grid"; import { EssaysGrid } from "./essays-grid";
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
@@ -17,10 +17,12 @@ export function generateMetadata(): Metadata {
} }
async function getAllEssays() { async function getAllEssays() {
return prisma.essay.findMany({ return bffList<{
orderBy: [{ group: "asc" }, { order: "asc" }], slug: string;
select: { slug: true, title: true, year: true, group: true }, title: string;
}); year: number | null;
group: string | null;
}>("/essays");
} }
export default async function EssaysPage() { export default async function EssaysPage() {
+144
View File
@@ -89,6 +89,78 @@
--reading-text-align: start; --reading-text-align: start;
} }
/* ── Light palette — "Tulasī Garden" ──
A fresh, distinctly GREEN palette — noticeably green background
(not just a green-tinted white), with deep forest text and primary. */
:root:not(.dark)[data-light-palette="tulasi-garden"] {
--background: oklch(0.92 0.07 152);
--foreground: oklch(0.22 0.09 155);
--card: oklch(0.95 0.055 150);
--card-foreground: oklch(0.22 0.09 155);
--popover: oklch(0.95 0.055 150);
--popover-foreground: oklch(0.22 0.09 155);
--primary: oklch(0.45 0.19 150);
--primary-foreground: oklch(0.97 0.04 150);
--secondary: oklch(0.85 0.1 152);
--secondary-foreground: oklch(0.22 0.09 155);
--muted: oklch(0.87 0.09 152);
--muted-foreground: oklch(0.42 0.11 150);
--accent: oklch(0.87 0.09 152);
--accent-foreground: oklch(0.22 0.09 155);
--border: oklch(0.76 0.12 150);
--input: oklch(0.76 0.12 150);
--ring: oklch(0.56 0.17 150);
--chart-1: oklch(0.45 0.19 150);
--chart-2: oklch(0.42 0.11 150);
--chart-3: oklch(0.56 0.17 150);
--chart-4: oklch(0.34 0.12 145);
--chart-5: oklch(0.22 0.09 155);
--sidebar: oklch(0.95 0.055 150);
--sidebar-foreground: oklch(0.22 0.09 155);
--sidebar-primary: oklch(0.45 0.19 150);
--sidebar-primary-foreground: oklch(0.97 0.04 150);
--sidebar-accent: oklch(0.87 0.09 152);
--sidebar-accent-foreground: oklch(0.22 0.09 155);
--sidebar-border: oklch(0.76 0.12 150);
--sidebar-ring: oklch(0.56 0.17 150);
}
/* ── Light palette — "Yamunā Mist" ──
A distinctly BLUE palette inspired by the sacred Yamunā river —
noticeably blue background (not just a blue-tinted white). */
:root:not(.dark)[data-light-palette="yamuna-mist"] {
--background: oklch(0.92 0.05 230);
--foreground: oklch(0.24 0.09 240);
--card: oklch(0.95 0.04 230);
--card-foreground: oklch(0.24 0.09 240);
--popover: oklch(0.95 0.04 230);
--popover-foreground: oklch(0.24 0.09 240);
--primary: oklch(0.52 0.2 240);
--primary-foreground: oklch(0.97 0.03 230);
--secondary: oklch(0.85 0.08 232);
--secondary-foreground: oklch(0.24 0.09 240);
--muted: oklch(0.87 0.07 232);
--muted-foreground: oklch(0.44 0.11 235);
--accent: oklch(0.87 0.07 232);
--accent-foreground: oklch(0.24 0.09 240);
--border: oklch(0.76 0.1 232);
--input: oklch(0.76 0.1 232);
--ring: oklch(0.62 0.15 235);
--chart-1: oklch(0.52 0.2 240);
--chart-2: oklch(0.44 0.11 235);
--chart-3: oklch(0.62 0.15 235);
--chart-4: oklch(0.38 0.13 240);
--chart-5: oklch(0.24 0.09 240);
--sidebar: oklch(0.95 0.04 230);
--sidebar-foreground: oklch(0.24 0.09 240);
--sidebar-primary: oklch(0.52 0.2 240);
--sidebar-primary-foreground: oklch(0.97 0.03 230);
--sidebar-accent: oklch(0.87 0.07 232);
--sidebar-accent-foreground: oklch(0.24 0.09 240);
--sidebar-border: oklch(0.76 0.1 232);
--sidebar-ring: oklch(0.62 0.15 235);
}
/* ── Dark mode — "Ancient Manuscript" ── */ /* ── Dark mode — "Ancient Manuscript" ── */
.dark { .dark {
--background: oklch(0.197 0.013 55.5); --background: oklch(0.197 0.013 55.5);
@@ -124,6 +196,78 @@
--sidebar-ring: oklch(0.636 0.08 86.9); --sidebar-ring: oklch(0.636 0.08 86.9);
} }
/* ── Dark palette — "Nīlamaṇi" ──
"Blue jewel" — Kṛṣṇa's sapphire complexion. Deep navy with
bright sky-blue accents — cool and distinctly BLUE. */
.dark[data-dark-palette="nilamani"] {
--background: oklch(0.16 0.05 255);
--foreground: oklch(0.92 0.04 220);
--card: oklch(0.21 0.06 252);
--card-foreground: oklch(0.92 0.04 220);
--popover: oklch(0.21 0.06 252);
--popover-foreground: oklch(0.92 0.04 220);
--primary: oklch(0.72 0.18 235);
--primary-foreground: oklch(0.16 0.05 255);
--secondary: oklch(0.28 0.06 248);
--secondary-foreground: oklch(0.92 0.04 220);
--muted: oklch(0.28 0.06 248);
--muted-foreground: oklch(0.7 0.07 230);
--accent: oklch(0.28 0.06 248);
--accent-foreground: oklch(0.92 0.04 220);
--border: oklch(0.7 0.12 235 / 22%);
--input: oklch(0.7 0.12 235 / 26%);
--ring: oklch(0.66 0.14 235);
--chart-1: oklch(0.72 0.18 235);
--chart-2: oklch(0.7 0.07 230);
--chart-3: oklch(0.66 0.14 235);
--chart-4: oklch(0.46 0.1 240);
--chart-5: oklch(0.28 0.06 248);
--sidebar: oklch(0.21 0.06 252);
--sidebar-foreground: oklch(0.92 0.04 220);
--sidebar-primary: oklch(0.72 0.18 235);
--sidebar-primary-foreground: oklch(0.16 0.05 255);
--sidebar-accent: oklch(0.28 0.06 248);
--sidebar-accent-foreground: oklch(0.92 0.04 220);
--sidebar-border: oklch(0.7 0.12 235 / 22%);
--sidebar-ring: oklch(0.66 0.14 235);
}
/* ── Dark palette — "Vraja Twilight" ──
The crimson dusk over Vraja — deep wine background with warm
rose-red accents. Distinctly RED/WARM, no overlap with the others. */
.dark[data-dark-palette="vraja-twilight"] {
--background: oklch(0.18 0.05 15);
--foreground: oklch(0.92 0.03 25);
--card: oklch(0.22 0.06 12);
--card-foreground: oklch(0.92 0.03 25);
--popover: oklch(0.22 0.06 12);
--popover-foreground: oklch(0.92 0.03 25);
--primary: oklch(0.72 0.18 25);
--primary-foreground: oklch(0.18 0.05 15);
--secondary: oklch(0.28 0.06 15);
--secondary-foreground: oklch(0.92 0.03 25);
--muted: oklch(0.28 0.06 15);
--muted-foreground: oklch(0.7 0.07 22);
--accent: oklch(0.28 0.06 15);
--accent-foreground: oklch(0.92 0.03 25);
--border: oklch(0.7 0.12 25 / 22%);
--input: oklch(0.7 0.12 25 / 26%);
--ring: oklch(0.66 0.14 25);
--chart-1: oklch(0.72 0.18 25);
--chart-2: oklch(0.7 0.07 22);
--chart-3: oklch(0.66 0.14 25);
--chart-4: oklch(0.46 0.1 20);
--chart-5: oklch(0.28 0.06 15);
--sidebar: oklch(0.22 0.06 12);
--sidebar-foreground: oklch(0.92 0.03 25);
--sidebar-primary: oklch(0.72 0.18 25);
--sidebar-primary-foreground: oklch(0.18 0.05 15);
--sidebar-accent: oklch(0.28 0.06 15);
--sidebar-accent-foreground: oklch(0.92 0.03 25);
--sidebar-border: oklch(0.7 0.12 25 / 22%);
--sidebar-ring: oklch(0.66 0.14 25);
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
+22 -2
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react"; import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -291,8 +291,28 @@ export function GlossaryClient({
letterCounts, letterCounts,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeTermRaw, setActiveTerm] = useState(entries[0]?.term || "");
// Initial active term: prefer ?term= URL param, then fall back to first entry
const initialTerm = useMemo(() => {
const termParam = searchParams.get("term");
if (termParam) {
// Match case-insensitively and ignore diacritics
const normalize = (s: string) =>
s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const normalizedParam = normalize(termParam);
const match = entries.find((e) => normalize(e.term) === normalizedParam);
if (match) return match.term;
}
return entries[0]?.term || "";
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [activeTermRaw, setActiveTerm] = useState(initialTerm);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
// All term names // All term names
+1 -1
View File
@@ -82,7 +82,7 @@ export function OtherDocumentsGrid({
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder="Search documents..." placeholder="Search documents by title..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9" className="pl-9"
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { buildOgMeta } from "@/lib/og";
import { SearchContent } from "./search-content";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Search",
detail: "Search across all content",
quote:
"Search across Śrīla Prabhupāda's books, letters, lectures, conversations, and more.",
});
}
export default function SearchPage() {
return <SearchContent />;
}
+693
View File
@@ -0,0 +1,693 @@
"use client";
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { buildSearchResultUrl } from "@/lib/text-highlight";
import {
Search,
Loader2,
BookOpen,
Mail,
Mic,
BookA,
PenLine,
NotebookPen,
Newspaper,
FileStack,
ChevronLeft,
ChevronRight,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SearchResult {
collection: string;
id: string;
title: string;
snippet: string;
url: string;
meta: Record<string, string | undefined>;
}
interface SearchResponse {
query: string;
type: string;
results: SearchResult[];
total: number;
page: number;
limit: number;
total_pages: number;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CONTENT_TABS = [
{ label: "All", value: "all" },
{ label: "Books", value: "books" },
{ label: "Letters", value: "letters" },
{ label: "Transcriptions", value: "transcriptions" },
{ label: "Glossary", value: "glossary" },
{ label: "Essays", value: "essays" },
{ label: "Journals", value: "journals" },
{ label: "Magazines", value: "magazines" },
{ label: "Other Documents", value: "other_documents" },
] as const;
// Maps a frontend tab value to the actual BFF collection name(s).
// "books" expands to verses + prose for the API call.
const TAB_TO_COLLECTIONS: Record<string, string[]> = {
books: ["verses", "prose"],
};
function tabsToBffType(tabs: Set<string>): string {
if (tabs.size === 0) return "all";
const collections: string[] = [];
for (const tab of tabs) {
const expanded = TAB_TO_COLLECTIONS[tab];
if (expanded) {
collections.push(...expanded);
} else {
collections.push(tab);
}
}
return collections.join(",");
}
function bffTypeToTabs(typeParam: string): Set<string> {
if (!typeParam || typeParam === "all") return new Set<string>();
const collections = typeParam
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const tabs = new Set<string>();
for (const col of collections) {
// If this collection is part of an expanded tab, use the tab name
let mapped = false;
for (const [tab, expanded] of Object.entries(TAB_TO_COLLECTIONS)) {
if (expanded.includes(col)) {
tabs.add(tab);
mapped = true;
break;
}
}
if (!mapped) tabs.add(col);
}
return tabs;
}
const COLLECTION_CONFIG: Record<
string,
{ label: string; icon: typeof BookOpen; color: string }
> = {
verses: {
label: "Verse",
icon: BookOpen,
color: "bg-primary/10 text-primary",
},
prose: {
label: "Prose",
icon: BookOpen,
color: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
},
letters: {
label: "Letter",
icon: Mail,
color: "bg-green-500/10 text-green-700 dark:text-green-400",
},
transcriptions: {
label: "Transcript",
icon: Mic,
color: "bg-purple-500/10 text-purple-700 dark:text-purple-400",
},
glossary: {
label: "Glossary",
icon: BookA,
color: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
},
essays: {
label: "Essay",
icon: PenLine,
color: "bg-teal-500/10 text-teal-700 dark:text-teal-400",
},
journals: {
label: "Journal",
icon: NotebookPen,
color: "bg-orange-500/10 text-orange-700 dark:text-orange-400",
},
magazines: {
label: "Magazine",
icon: Newspaper,
color: "bg-pink-500/10 text-pink-700 dark:text-pink-400",
},
other_documents: {
label: "Document",
icon: FileStack,
color: "bg-muted text-muted-foreground",
},
};
// ---------------------------------------------------------------------------
// Debounce hook
// ---------------------------------------------------------------------------
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// ---------------------------------------------------------------------------
// Inner search component (uses useSearchParams)
// ---------------------------------------------------------------------------
function SearchInner() {
const searchParams = useSearchParams();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
// Read initial state from URL
const initialQuery = searchParams.get("q") || "";
const initialType = searchParams.get("type") || "all";
const initialPage = parseInt(searchParams.get("page") || "1", 10);
const [inputValue, setInputValue] = useState(initialQuery);
// selectedTypes holds tab values (e.g., "books", "letters") — not raw collection names.
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(() =>
bffTypeToTabs(initialType),
);
const [currentPage, setCurrentPage] = useState(initialPage);
// Convert selected tabs to the BFF type param ("books" → "verses,prose")
const activeTypeParam = tabsToBffType(selectedTypes);
// For URL: keep the tab-friendly form ("books") instead of the expanded form
const urlTypeParam =
selectedTypes.size === 0 ? "all" : Array.from(selectedTypes).join(",");
const [data, setData] = useState<SearchResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const debouncedQuery = useDebouncedValue(inputValue, 300);
const isQueryEmpty = !debouncedQuery.trim();
// When the query is empty, derive these as "no results" during render
// instead of setting state inside an effect.
const effectiveData = isQueryEmpty ? null : data;
const hasSearched = isQueryEmpty ? false : !!debouncedQuery.trim();
// Sync URL when state changes
const pushUrl = useCallback(
(q: string, typeParam: string, page: number) => {
const params = new URLSearchParams();
if (q.trim()) params.set("q", q.trim());
if (typeParam !== "all") params.set("type", typeParam);
if (page > 1) params.set("page", String(page));
const qs = params.toString();
router.replace(`/search${qs ? `?${qs}` : ""}`, { scroll: false });
},
[router],
);
// Reset page to 1 when query or type changes.
// Uses the "compare previous value during render" pattern
// recommended by React instead of a setState-in-effect.
const [prevQuery, setPrevQuery] = useState(debouncedQuery);
const [prevType, setPrevType] = useState(activeTypeParam);
if (prevQuery !== debouncedQuery || prevType !== activeTypeParam) {
setPrevQuery(debouncedQuery);
setPrevType(activeTypeParam);
setCurrentPage(1);
}
// Fetch search results
useEffect(() => {
const query = debouncedQuery.trim();
if (!query) {
pushUrl("", urlTypeParam, 1);
return;
}
setIsLoading(true);
pushUrl(query, urlTypeParam, currentPage);
const controller = new AbortController();
const params = new URLSearchParams({
q: query,
type: activeTypeParam,
page: String(currentPage),
limit: "20",
});
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
.then((res) => res.json())
.then((json: SearchResponse) => {
// Defensive: ensure results is always an array (BFF may return null)
setData({ ...json, results: json.results ?? [] });
setIsLoading(false);
})
.catch((err) => {
if (err.name !== "AbortError") {
setIsLoading(false);
setData({
query,
type: activeTypeParam,
results: [],
total: 0,
page: 1,
limit: 20,
total_pages: 0,
});
}
});
return () => controller.abort();
}, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, pushUrl]);
// Auto-focus the input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleTypeChange = (type: string) => {
if (type === "all") {
// "All" clears all individual selections
setSelectedTypes(new Set());
} else {
setSelectedTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
// Deselect — if nothing left, goes back to "all"
next.delete(type);
} else {
next.add(type);
}
return next;
});
}
};
const isTypeActive = (type: string): boolean => {
if (type === "all") return selectedTypes.size === 0;
return selectedTypes.has(type);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: "smooth" });
};
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">Search</h1>
<p className="mt-1 text-muted-foreground">
Search across all of Śrīla Prabhupāda&apos;s books, letters, lectures,
and more
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
type="search"
placeholder="Search for verses, letters, lectures, terms..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="h-14 pl-12 pr-4 text-lg rounded-xl"
/>
{isLoading && (
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground animate-spin" />
)}
</div>
{/* Content Type Tabs */}
<div className="mt-6 flex flex-wrap gap-2">
{CONTENT_TABS.map((tab) => {
const active = isTypeActive(tab.value);
return (
<button
key={tab.value}
onClick={() => handleTypeChange(tab.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
)}
>
{tab.label}
{effectiveData &&
active &&
selectedTypes.size <= 1 &&
effectiveData.total > 0 && (
<span
className={cn(
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none",
"bg-primary-foreground/20 text-primary-foreground",
)}
>
{effectiveData.total.toLocaleString()}
</span>
)}
</button>
);
})}
</div>
{/* Results Area */}
<div className="mt-8">
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span>Searching…</span>
</div>
)}
{/* No query */}
{!isLoading && !hasSearched && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Search className="size-12 text-muted-foreground/40 mb-4" />
<p className="text-lg text-muted-foreground">
Search across all of Śrīla Prabhupāda&apos;s books, letters,
lectures, and more
</p>
</div>
)}
{/* No results */}
{!isLoading &&
hasSearched &&
effectiveData &&
effectiveData.results.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Search className="size-12 text-muted-foreground/40 mb-4" />
<p className="text-lg text-muted-foreground">
No results found for &ldquo;{debouncedQuery}&rdquo;.
<br />
<span className="text-sm">
Try a different search term or content type.
</span>
</p>
</div>
)}
{/* Results */}
{!isLoading && effectiveData && effectiveData.results.length > 0 && (
<>
{/* Results count + top pagination */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
<p className="text-sm text-muted-foreground">
{effectiveData.total.toLocaleString()}{" "}
{effectiveData.total === 1 ? "result " : "results "} for
&ldquo;
{effectiveData.query}&rdquo;
</p>
{effectiveData.total_pages > 1 && (
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
onPageChange={handlePageChange}
compact
/>
)}
</div>
{/* Result list */}
<div className="space-y-3">
{effectiveData.results.map((result, i) => (
<ResultCard
key={`${result.collection}-${result.id || i}`}
result={result}
/>
))}
</div>
{/* Bottom pagination */}
{effectiveData.total_pages > 1 && (
<div className="mt-8">
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
total={effectiveData.total}
onPageChange={handlePageChange}
/>
</div>
)}
</>
)}
</div>
</main>
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination Controls
// ---------------------------------------------------------------------------
function PaginationControls({
page,
totalPages,
total,
onPageChange,
compact = false,
}: {
page: number;
totalPages: number;
total?: number;
onPageChange: (page: number) => void;
compact?: boolean;
}) {
if (compact) {
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</Button>
</div>
);
}
return (
<div className="flex items-center justify-between">
<Button
variant="outline"
size="default"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{total != null && ` · ${total.toLocaleString()} results`}
</span>
<Button
variant="outline"
size="default"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Result Card
// ---------------------------------------------------------------------------
function ResultCard({ result }: { result: SearchResult }) {
const config = COLLECTION_CONFIG[result.collection] || {
label: result.collection,
icon: FileStack,
color: "bg-muted text-muted-foreground",
};
const Icon = config.icon;
// Append `?hl=<matched-text>` so the destination page highlights the snippet
// text Typesense matched. Falls back to the original URL if no marks were
// present in the snippet.
const href = buildSearchResultUrl(result.url, result.snippet);
return (
<Link
href={href}
className="block group"
onClick={() => {
try {
sessionStorage.setItem("spsays_from_search", "1");
} catch {
/* sessionStorage unavailable */
}
}}
>
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20 cursor-pointer">
<CardContent className="flex flex-col gap-2">
{/* Collection badge */}
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
config.color,
)}
>
<Icon className="size-3" />
{config.label}
</span>
</div>
{/* Title */}
<p className="text-base font-semibold text-foreground group-hover:text-primary transition-colors leading-snug">
{result.title}
</p>
{/* Snippet with highlighted matches */}
<p
className="text-sm text-muted-foreground leading-relaxed [&_mark]:bg-yellow-200/80 [&_mark]:text-foreground [&_mark]:rounded-sm [&_mark]:px-0.5 dark:[&_mark]:bg-yellow-500/30"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
{/* Meta information */}
<ResultMeta result={result} />
</CardContent>
</Card>
</Link>
);
}
// ---------------------------------------------------------------------------
// Result Meta — varies by collection
// ---------------------------------------------------------------------------
function ResultMeta({ result }: { result: SearchResult }) {
const { collection, meta } = result;
if (!meta || Object.keys(meta).length === 0) return null;
let items: string[] = [];
switch (collection) {
case "verses":
if (meta.book) items.push(meta.book);
if (meta.reference) items.push(meta.reference);
break;
case "letters":
if (meta.date) items.push(meta.date);
if (meta.location) items.push(meta.location);
break;
case "transcriptions":
if (meta.type) items.push(meta.type);
if (meta.date) items.push(meta.date);
if (meta.place) items.push(meta.place);
break;
case "glossary":
if (meta.term) items.push(meta.term);
break;
case "prose":
case "essays":
case "journals":
case "magazines":
case "other_documents":
if (meta.book) items.push(meta.book);
break;
default:
// Show all meta values
items = Object.values(meta).filter(Boolean) as string[];
}
if (items.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1.5">
{items.map((item, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
{item}
</Badge>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Exported wrapper with Suspense boundary
// ---------------------------------------------------------------------------
export function SearchContent() {
return (
<Suspense
fallback={
<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">Search</h1>
<p className="mt-1 text-muted-foreground">
Search across all of Śrīla Prabhupāda&apos;s books, letters,
lectures, and more
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
</main>
</div>
}
>
<SearchInner />
</Suspense>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
export default function ShareNotFound() {
return (
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
<h1 className="text-2xl font-semibold">Link not found</h1>
<p className="text-muted-foreground mt-3 text-sm">
This short link may have expired or never existed.
</p>
<Link
href="/"
className="text-primary mt-6 text-sm underline underline-offset-4"
>
Return to Prabhupāda Says →
</Link>
</main>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { resolveShortLink } from "@/lib/share";
import { buildShareOgMeta } from "@/lib/share-og";
import { ogDefault } from "@/lib/og";
import { RedirectClient } from "./redirect-client";
type PageProps = { params: Promise<{ code: string }> };
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { code } = await params;
const fullPath = await resolveShortLink(code);
if (!fullPath) {
return {
title: "Link expired — Prabhupāda Says",
robots: { index: false, follow: false },
...ogDefault(),
};
}
return buildShareOgMeta(fullPath);
}
export default async function SharePage({ params }: PageProps) {
const { code } = await params;
const fullPath = await resolveShortLink(code);
if (!fullPath) notFound();
return (
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
<p className="text-muted-foreground text-sm">
Redirecting to your highlight…
</p>
<noscript>
<p className="mt-4 text-sm">
JavaScript is disabled.{" "}
<a className="underline" href={fullPath}>
Continue to the page
</a>
.
</p>
</noscript>
<RedirectClient to={fullPath} />
</main>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
// Client-side redirect: we deliberately don't use `redirect()` from
// next/navigation on the server, because that returns a 307 before the OG
// `<head>` is ever served. With `generateMetadata` + a 200 body, social-link
// scrapers get the full OG card while real users still get redirected.
export function RedirectClient({ to }: { to: string }) {
const router = useRouter();
useEffect(() => {
router.replace(to);
}, [router, to]);
return null;
}
+37 -13
View File
@@ -7,7 +7,7 @@ import {
getTranscription, getTranscription,
getAdjacentTranscriptions, getAdjacentTranscriptions,
} from "@/lib/transcriptions"; } from "@/lib/transcriptions";
import { buildOgMeta } from "@/lib/og"; import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -18,27 +18,51 @@ import { PlayAudioButton } from "./play-audio-button";
export async function generateMetadata({ export async function generateMetadata({
params, params,
searchParams,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const sp = await searchParams;
const t = await getTranscription(slug); const t = await getTranscription(slug);
if (!t) return {}; if (!t) return {};
const detail = [t.date, t.place].filter(Boolean).join(" · "); const detail = [t.date, t.place].filter(Boolean).join(" · ");
// Build paragraphs from content blocks for hl matching
const paragraphs = t.content.map((b) => {
const speakerPrefix = b.speaker ? `${b.speaker}: ` : "";
return speakerPrefix + b.text.replace(/<[^>]+>/g, "");
});
const hlQuote = extractQuoteFromHl(sp, paragraphs);
if (hlQuote) {
return buildOgMeta({
title: `Transcripts: ${t.name}`,
detail: detail || undefined,
quote: hlQuote,
});
}
// Fallback: find a good Prabhupāda quote from the content
const spQuote = t.content
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
?.text?.replace(/<[^>]+>/g, "")
.slice(0, 200);
if (spQuote) {
return buildOgMeta({ return buildOgMeta({
title: t.name, title: t.name,
detail: detail || undefined, detail: detail || undefined,
quote: quote: spQuote,
t.content.length > 0
? t.content
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
?.text?.replace(/<[^>]+>/g, "")
.slice(0, 200)
: undefined,
}); });
} }
return ogForTranscription(`Transcripts: ${t.name}`, detail || undefined);
}
export default async function TranscriptionPage({ export default async function TranscriptionPage({
params, params,
}: { }: {
@@ -93,16 +117,16 @@ export default async function TranscriptionPage({
<TranscriptionNav prev={prev} next={next} /> <TranscriptionNav prev={prev} next={next} />
{/* Audio + Participants row */} {/* Audio + Participants row */}
<div className="w-full flex flex-col md:flex-row items-start md:items-end justify-between gap-4"> <div className="w-full flex flex-col md:flex-row items-start md:items-start justify-between gap-4">
{/* Participants */} {/* Participants */}
{t.participants.length > 0 && ( {t.participants.length > 0 && (
<> <>
<div className="flex flex-col gap-2"> <div className="">
<div className="flex pl-2 items-center gap-2 flex-wrap text-sm text-muted-foreground"> <div className="flex flex-row pl-2 mb-2 items-center gap-2 text-sm text-muted-foreground">
<Users className="size-3.5" /> <Users className="size-3.5" />
<span>Participants:</span> <span>Participants:</span>
</div> </div>
<div className="flex items-center gap-2 flex-wrap text-sm text-muted-foreground"> <div className="max-w-full flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
{t.participants.map((p, i) => ( {t.participants.map((p, i) => (
<Badge <Badge
key={i} key={i}
@@ -119,7 +143,7 @@ export default async function TranscriptionPage({
{/* Play Audio button */} {/* Play Audio button */}
{t.audioUrl && ( {t.audioUrl && (
<div className="w-full md:w-auto flex justify-end"> <div className="w-full md:w-[25%] flex justify-end">
<PlayAudioButton <PlayAudioButton
audioUrl={t.audioUrl} audioUrl={t.audioUrl}
title={t.name} title={t.name}
@@ -1,8 +1,12 @@
"use client"; "use client";
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player"; import {
useAudioPlayer,
type AudioTrack,
AudioWaveAnimation,
} from "@/components/audio-player";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Play, Pause, Radio } from "lucide-react"; import { Play, Pause } from "lucide-react";
type PlayAudioButtonProps = { type PlayAudioButtonProps = {
audioUrl: string; audioUrl: string;
@@ -63,7 +67,11 @@ export function PlayAudioButton({
aria-label={isThisPlaying ? "Pause" : "Play audio"} aria-label={isThisPlaying ? "Pause" : "Play audio"}
> >
{isThisPlaying ? ( {isThisPlaying ? (
<Pause className="size-3.5" /> <AudioWaveAnimation
paused={false}
colorClassName="bg-primary-foreground"
size="sm"
/>
) : ( ) : (
<Play className="size-3.5 ml-0.5" /> <Play className="size-3.5 ml-0.5" />
)} )}
@@ -85,7 +93,11 @@ export function PlayAudioButton({
> >
{isThisPlaying ? ( {isThisPlaying ? (
<> <>
<Radio className="size-4 animate-pulse" /> <AudioWaveAnimation
paused={false}
colorClassName="bg-primary-foreground"
size="sm"
/>
<span>Now Playing</span> <span>Now Playing</span>
</> </>
) : ( ) : (
+2 -7
View File
@@ -3,7 +3,7 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og"; import { ogForTranscriptions } from "@/lib/og";
import { import {
getTranscriptionYears, getTranscriptionYears,
getTranscriptionPlaces, getTranscriptionPlaces,
@@ -14,12 +14,7 @@ import { TranscriptionsNav } from "./transcriptions-nav";
import { TranscriptionsBrowser } from "./transcriptions-browser"; import { TranscriptionsBrowser } from "./transcriptions-browser";
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
return buildOgMeta({ return ogForTranscriptions();
title: "Transcriptions",
detail: "3,700+ lectures & conversations (1966–1977)",
quote:
"Transcriptions of Śrīla Prabhupāda's lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text.",
});
} }
export default async function TranscriptionsPage({ export default async function TranscriptionsPage({
+13 -2
View File
@@ -8,7 +8,8 @@ import { buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Clock } from "lucide-react"; import { Clock, MapPin } from "lucide-react";
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -60,7 +61,7 @@ export default async function TranscriptionsByPlacePage({
<Link <Link
key={t.slug} key={t.slug}
href={`/transcriptions/${t.slug}`} href={`/transcriptions/${t.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors" className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -87,6 +88,16 @@ export default async function TranscriptionsByPlacePage({
)} )}
</div> </div>
</div> </div>
{t.audioUrl && (
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
variant="compact"
/>
)}
</Link> </Link>
))} ))}
</CardContent> </CardContent>
@@ -8,6 +8,7 @@ import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings"; import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react"; import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -34,6 +35,7 @@ export type TranscriptionItem = {
reference: string | null; reference: string | null;
durationMinutes: number | null; durationMinutes: number | null;
participants: string[]; participants: string[];
audioUrl: string | null;
}; };
type YearGroup = { type YearGroup = {
@@ -364,7 +366,7 @@ function MonthSection({
<li key={t.slug}> <li key={t.slug}>
<Link <Link
href={`/transcriptions/${t.slug}`} href={`/transcriptions/${t.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 pl-12 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors" className="flex items-center px-5 pl-12 py-3.5 gap-3 hover:bg-muted transition-colors"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -396,6 +398,16 @@ function MonthSection({
)} )}
</div> </div>
</div> </div>
{t.audioUrl && (
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
variant="compact"
/>
)}
</Link> </Link>
</li> </li>
))} ))}
+2 -2
View File
@@ -17,8 +17,8 @@ export async function generateMetadata({
const transcriptions = await getTranscriptionsByYear(year); const transcriptions = await getTranscriptionsByYear(year);
return buildOgMeta({ return buildOgMeta({
title: "Transcriptions", title: "Transcriptions",
detail: `${transcriptions.length} transcriptions from ${year}`, detail: `${transcriptions.length} lectures & conversations from ${year}`,
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year}.`, quote: `Lectures and conversations of Śrīla Prabhupāda from ${year} — the living voice of the Founder-Ācārya preserved in text.`,
}); });
} }
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
/**
* Animated audio wave bars — used in the minimized player pill
* and the "Now Playing" button.
*
* @param paused – when true the bars flatten to a static state
* @param colorClassName – Tailwind color class for the bars (default: "bg-current")
* @param className – additional classes on the container
* @param barCount – number of bars (default: 4)
* @param size – "sm" (10px tall) | "md" (12px tall, default) | "lg" (16px tall)
*/
export function AudioWaveAnimation({
paused = false,
colorClassName = "bg-current",
className,
barCount = 4,
size = "md",
}: {
paused?: boolean;
colorClassName?: string;
className?: string;
barCount?: number;
size?: "sm" | "md" | "lg";
}) {
const config = {
sm: { height: 10, minH: 2, barW: "w-[2px]", containerH: "h-2.5" },
md: { height: 12, minH: 3, barW: "w-[3px]", containerH: "h-3" },
lg: { height: 16, minH: 3, barW: "w-[3px]", containerH: "h-4" },
}[size];
return (
<div
className={cn("flex items-end gap-[2px]", config.containerH, className)}
>
{Array.from({ length: barCount }, (_, i) => (
<motion.div
key={i}
className={cn(config.barW, "rounded-full", colorClassName)}
animate={{
height: paused
? config.minH
: [config.minH, config.height, config.minH],
}}
transition={{
duration: 0.8,
repeat: paused ? 0 : Infinity,
repeatType: "mirror",
ease: "easeInOut",
delay: i * 0.15,
}}
/>
))}
</div>
);
}
+240 -34
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import { useRef, useCallback, useEffect } from "react"; import { useRef, useCallback, useEffect, useState } from "react";
import { useAudioPlayer } from "./audio-context"; import { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import { import {
Play, Play,
Pause, Pause,
@@ -18,6 +20,64 @@ import {
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
// ---------------------------------------------------------------------------
// Mobile auto-minimize hook
// ---------------------------------------------------------------------------
const MINIMIZE_DELAY = 8000;
function useIsMobile() {
const [mobile, setMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
setMobile(mq.matches);
const handler = (e: MediaQueryListEvent) => setMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return mobile;
}
/**
* Returns [minimized, setMinimized, resetTimer].
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity on mobile.
* Call `resetTimer()` on any user interaction to restart the countdown.
*/
function useAutoMinimize(hasTrack: boolean) {
const [minimized, setMinimized] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const startTimer = useCallback(() => {
clearTimer();
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
}, [clearTimer]);
const resetTimer = useCallback(() => {
setMinimized(false);
startTimer();
}, [startTimer]);
// Start timer when track begins
useEffect(() => {
if (hasTrack) {
startTimer();
} else {
clearTimer();
setMinimized(false);
}
return clearTimer;
}, [hasTrack, startTimer, clearTimer]);
return { minimized, setMinimized, resetTimer } as const;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -230,6 +290,28 @@ export function FloatingPlayer() {
} = useAudioPlayer(); } = useAudioPlayer();
const playerRef = useRef<HTMLDivElement>(null); const playerRef = useRef<HTMLDivElement>(null);
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
const isMobile = useIsMobile();
const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack);
// Watch for the selection toolbar appearing/disappearing in the DOM
useEffect(() => {
if (!currentTrack) return;
const check = () => {
const toolbar = document.querySelector("[data-selection-toolbar]");
// The toolbar wrapper is always present; check if it has visible children
setSelectionToolbarVisible(!!toolbar && toolbar.childElementCount > 0);
};
check();
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [currentTrack]);
// Add bottom padding to body when player is visible // Add bottom padding to body when player is visible
useEffect(() => { useEffect(() => {
@@ -240,47 +322,81 @@ export function FloatingPlayer() {
}; };
}, [currentTrack]); }, [currentTrack]);
// Wrap interactions to reset the minimize timer
const interact = useCallback(
<T extends unknown[]>(fn: (...args: T) => void) =>
(...args: T) => {
resetTimer();
fn(...args);
},
[resetTimer],
);
const iTogglePlayPause = interact(togglePlayPause);
const iSeekTo = interact(seekTo);
const iSetVolume = interact(setVolume);
const iToggleMute = interact(toggleMute);
const iNext = interact(next);
const iPrevious = interact(previous);
const iStop = interact(stop);
// Minimize when clicking/tapping outside the player
useEffect(() => {
if (!currentTrack || minimized) return;
function handleOutsideClick(e: PointerEvent) {
const player = playerRef.current;
if (!player) return;
if (player.contains(e.target as Node)) return;
setMinimized(true);
}
document.addEventListener("pointerdown", handleOutsideClick);
return () =>
document.removeEventListener("pointerdown", handleOutsideClick);
}, [currentTrack, minimized, setMinimized]);
// Keyboard shortcuts when player is focused // Keyboard shortcuts when player is focused
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
switch (e.key) { switch (e.key) {
case " ": case " ":
e.preventDefault(); e.preventDefault();
togglePlayPause(); iTogglePlayPause();
break; break;
case "ArrowLeft": case "ArrowLeft":
e.preventDefault(); e.preventDefault();
seekTo(Math.max(0, currentTime - 10)); iSeekTo(Math.max(0, currentTime - 10));
break; break;
case "ArrowRight": case "ArrowRight":
e.preventDefault(); e.preventDefault();
seekTo(Math.min(duration, currentTime + 10)); iSeekTo(Math.min(duration, currentTime + 10));
break; break;
case "ArrowUp": case "ArrowUp":
e.preventDefault(); e.preventDefault();
setVolume(Math.min(1, volume + 0.05)); iSetVolume(Math.min(1, volume + 0.05));
break; break;
case "ArrowDown": case "ArrowDown":
e.preventDefault(); e.preventDefault();
setVolume(Math.max(0, volume - 0.05)); iSetVolume(Math.max(0, volume - 0.05));
break; break;
case "m": case "m":
case "M": case "M":
e.preventDefault(); e.preventDefault();
toggleMute(); iToggleMute();
break; break;
case "Escape": case "Escape":
e.preventDefault(); e.preventDefault();
stop(); iStop();
break; break;
} }
}, },
[ [
togglePlayPause, iTogglePlayPause,
seekTo, iSeekTo,
setVolume, iSetVolume,
toggleMute, iToggleMute,
stop, iStop,
currentTime, currentTime,
duration, duration,
volume, volume,
@@ -292,18 +408,105 @@ export function FloatingPlayer() {
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1; const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
const hasPrev = currentIndex > 0; const hasPrev = currentIndex > 0;
const coverSrc = currentTrack.coverImage || FALLBACK_COVER; const coverSrc = currentTrack.coverImage || FALLBACK_COVER;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
// ── Hide entirely when selection toolbar is active ──
if (selectionToolbarVisible) return null;
// ── Render ──
return ( return (
<div <AnimatePresence mode="popLayout">
{minimized ? (
<motion.button
key="mini-pill"
data-audio-player
type="button"
onClick={() => {
setMinimized(false);
resetTimer();
}}
aria-label="Expand audio player"
initial={{ opacity: 0, scale: 0.5, bottom: -60 }}
animate={{
opacity: 1,
scale: 1,
bottom: selectionToolbarVisible ? 90 : 24,
}}
exit={{ opacity: 0, scale: 0.5, bottom: -60 }}
transition={{ type: "spring", damping: 20, stiffness: 400 }}
className={cn(
"fixed z-40 right-4",
"size-14 rounded-full",
"shadow-2xl shadow-black/30",
"overflow-hidden",
"ring-2 ring-white/20",
)}
>
{/* Cover image */}
<Image
src={coverSrc}
alt=""
fill
className="object-cover"
unoptimized
/>
{/* Dark overlay */}
<div className="absolute inset-0 bg-black/40" />
{/* Circular progress ring */}
<svg
className="absolute inset-0 size-full -rotate-90"
viewBox="0 0 56 56"
>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="white"
strokeOpacity={0.15}
strokeWidth="2"
/>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="white"
strokeOpacity={0.8}
strokeWidth="2"
strokeDasharray={2 * Math.PI * 26}
strokeDashoffset={2 * Math.PI * 26 * (1 - progress / 100)}
strokeLinecap="round"
className="transition-[stroke-dashoffset] duration-300"
/>
</svg>
{/* Audio wave indicator */}
<div className="absolute inset-0 flex items-center justify-center">
<AudioWaveAnimation
paused={!isPlaying}
colorClassName="bg-white/90"
/>
</div>
</motion.button>
) : (
<motion.div
key="full-player"
ref={playerRef} ref={playerRef}
data-audio-player data-audio-player
role="region" role="region"
aria-label="Audio player" aria-label="Audio player"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
initial={{ opacity: 0, bottom: -80 }}
animate={{
opacity: 1,
bottom: selectionToolbarVisible ? 64 : 20,
}}
exit={{ opacity: 0, bottom: -80 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className={cn( className={cn(
// Centered floating bar // Centered floating bar
"fixed z-40 bottom-5 left-1/2 -translate-x-1/2", "fixed z-40 left-1/2 -translate-x-1/2",
// Narrower width // Narrower width
"w-[calc(100%-2rem)] max-w-[680px]", "w-[calc(100%-2rem)] max-w-[680px]",
// Forced dark surface for contrast regardless of theme // Forced dark surface for contrast regardless of theme
@@ -311,8 +514,6 @@ export function FloatingPlayer() {
"border border-white/10", "border border-white/10",
"rounded-2xl", "rounded-2xl",
"shadow-2xl shadow-black/25", "shadow-2xl shadow-black/25",
// Animation
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300",
// Clip seeker to rounded top corners // Clip seeker to rounded top corners
"overflow-hidden", "overflow-hidden",
)} )}
@@ -321,7 +522,7 @@ export function FloatingPlayer() {
<ProgressBar <ProgressBar
currentTime={currentTime} currentTime={currentTime}
duration={duration} duration={duration}
onSeek={seekTo} onSeek={iSeekTo}
thin thin
/> />
@@ -364,7 +565,7 @@ export function FloatingPlayer() {
{/* Center: Transport controls */} {/* Center: Transport controls */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<TransportButton <TransportButton
onClick={previous} onClick={iPrevious}
disabled={!hasPrev && currentTime <= 3} disabled={!hasPrev && currentTime <= 3}
label="Previous track" label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10" className="text-white/60 hover:text-white hover:bg-white/10"
@@ -373,7 +574,7 @@ export function FloatingPlayer() {
</TransportButton> </TransportButton>
<TransportButton <TransportButton
onClick={togglePlayPause} onClick={iTogglePlayPause}
label={isPlaying ? "Pause" : "Play"} label={isPlaying ? "Pause" : "Play"}
size="large" size="large"
className="bg-white text-black hover:bg-white/90" className="bg-white text-black hover:bg-white/90"
@@ -388,7 +589,7 @@ export function FloatingPlayer() {
</TransportButton> </TransportButton>
<TransportButton <TransportButton
onClick={next} onClick={iNext}
disabled={!hasNext} disabled={!hasNext}
label="Next track" label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10" className="text-white/60 hover:text-white hover:bg-white/10"
@@ -403,7 +604,7 @@ export function FloatingPlayer() {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
onClick={toggleMute} onClick={iToggleMute}
aria-label={isMuted ? "Unmute" : "Mute"} aria-label={isMuted ? "Unmute" : "Mute"}
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring" className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
@@ -419,7 +620,7 @@ export function FloatingPlayer() {
max={100} max={100}
step={1} step={1}
value={isMuted ? 0 : Math.round(volume * 100)} value={isMuted ? 0 : Math.round(volume * 100)}
onChange={(e) => setVolume(Number(e.target.value) / 100)} onChange={(e) => iSetVolume(Number(e.target.value) / 100)}
aria-label="Volume" aria-label="Volume"
className={cn( className={cn(
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer", "w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
@@ -448,11 +649,11 @@ export function FloatingPlayer() {
</Link> </Link>
)} )}
{/* Close */} {/* Minimize (playing) or Close (paused) */}
<button <button
type="button" type="button"
onClick={stop} onClick={isPlaying ? () => setMinimized(true) : iStop}
aria-label="Close player" aria-label={isPlaying ? "Minimize player" : "Close player"}
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring" className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<X className="size-4" /> <X className="size-4" />
@@ -461,7 +662,10 @@ export function FloatingPlayer() {
</div> </div>
{/* ── Mobile layout ── */} {/* ── Mobile layout ── */}
<div className="flex md:hidden flex-col px-4 py-3 gap-2.5"> <div
className="flex md:hidden flex-col px-4 py-3 gap-2.5"
onPointerDown={resetTimer}
>
{/* Time row — right below seeker */} {/* Time row — right below seeker */}
<div className="flex items-center justify-between px-0.5"> <div className="flex items-center justify-between px-0.5">
<span className="text-[10px] tabular-nums text-white/40 select-none"> <span className="text-[10px] tabular-nums text-white/40 select-none">
@@ -508,11 +712,11 @@ export function FloatingPlayer() {
</Link> </Link>
)} )}
{/* Close */} {/* Minimize (playing) or Close (paused) */}
<button <button
type="button" type="button"
onClick={stop} onClick={isPlaying ? () => setMinimized(true) : iStop}
aria-label="Close player" aria-label={isPlaying ? "Minimize player" : "Close player"}
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0" className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
> >
<X className="size-4" /> <X className="size-4" />
@@ -522,7 +726,7 @@ export function FloatingPlayer() {
{/* Row 2: Prev + Play/Pause + Next — centered */} {/* Row 2: Prev + Play/Pause + Next — centered */}
<div className="flex items-center justify-center gap-4 py-1"> <div className="flex items-center justify-center gap-4 py-1">
<TransportButton <TransportButton
onClick={previous} onClick={iPrevious}
disabled={!hasPrev && currentTime <= 3} disabled={!hasPrev && currentTime <= 3}
label="Previous track" label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10" className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
@@ -531,7 +735,7 @@ export function FloatingPlayer() {
</TransportButton> </TransportButton>
<TransportButton <TransportButton
onClick={togglePlayPause} onClick={iTogglePlayPause}
label={isPlaying ? "Pause" : "Play"} label={isPlaying ? "Pause" : "Play"}
size="large" size="large"
className="bg-white text-black hover:bg-white/90 !size-12" className="bg-white text-black hover:bg-white/90 !size-12"
@@ -546,7 +750,7 @@ export function FloatingPlayer() {
</TransportButton> </TransportButton>
<TransportButton <TransportButton
onClick={next} onClick={iNext}
disabled={!hasNext} disabled={!hasNext}
label="Next track" label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10" className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
@@ -555,6 +759,8 @@ export function FloatingPlayer() {
</TransportButton> </TransportButton>
</div> </div>
</div> </div>
</div> </motion.div>
)}
</AnimatePresence>
); );
} }
+1
View File
@@ -1,3 +1,4 @@
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context"; export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
export type { AudioTrack } from "./audio-context"; export type { AudioTrack } from "./audio-context";
export { FloatingPlayer } from "./floating-player"; export { FloatingPlayer } from "./floating-player";
export { AudioWaveAnimation } from "./audio-wave";
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { useRef, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { motion } from "framer-motion";
import { Search, CornerDownLeft } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* HeaderSearchButton — a search icon in the page header that expands
* into a full search input on click. Typing a query and pressing Enter
* (or clicking the search icon again) navigates to /search?q=...
*
* State is controlled by the parent (PageHeader) so the header can
* hide/show adjacent elements (logo, reading settings) on mobile.
*
* Pressing Escape or clicking outside collapses the input.
* Keyboard shortcut: "/" focuses the search from anywhere on the page.
*/
/** Map the current pathname to the search `type` filter value. */
function searchTypeFromPath(pathname: string): string | null {
if (pathname.startsWith("/books")) return "books";
if (pathname.startsWith("/letters")) return "letters";
if (pathname.startsWith("/essays")) return "essays";
if (pathname.startsWith("/transcriptions")) return "transcriptions";
if (pathname.startsWith("/glossary")) return "glossary";
if (pathname.startsWith("/journals")) return "journals";
if (pathname.startsWith("/magazines")) return "magazines";
if (pathname.startsWith("/other-documents")) return "other_documents";
return null; // homepage, search page, about, etc. → "all"
}
export function HeaderSearchButton({
expanded,
onExpandedChange,
}: {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
}) {
const queryRef = useRef("");
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const pathname = usePathname();
// Focus input when expanded
useEffect(() => {
if (expanded && inputRef.current) {
const timer = setTimeout(() => inputRef.current?.focus(), 150);
return () => clearTimeout(timer);
}
}, [expanded]);
// Close on outside click
useEffect(() => {
if (!expanded) return;
function handleClick(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onExpandedChange(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [expanded, onExpandedChange]);
// Global "/" shortcut to open search
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
) {
return;
}
if (e.key === "/") {
e.preventDefault();
onExpandedChange(true);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onExpandedChange]);
const handleSubmit = useCallback(() => {
const trimmed = queryRef.current.trim();
const params = new URLSearchParams();
if (trimmed) params.set("q", trimmed);
// Pre-select the search filter matching the current section
const type = searchTypeFromPath(pathname);
if (type) params.set("type", type);
const qs = params.toString();
router.push(`/search${qs ? `?${qs}` : ""}`);
onExpandedChange(false);
queryRef.current = "";
if (inputRef.current) inputRef.current.value = "";
}, [router, onExpandedChange, pathname]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
onExpandedChange(false);
queryRef.current = "";
if (inputRef.current) inputRef.current.value = "";
}
},
[handleSubmit, onExpandedChange],
);
return (
<div
ref={containerRef}
className={cn(
"relative flex items-center",
expanded && "max-md:flex-1 max-md:min-w-0",
)}
>
{expanded ? (
<motion.div
key="search-input"
initial={{ width: 36, opacity: 0 }}
animate={{ width: "100%", opacity: 1 }}
transition={{
width: { type: "spring", stiffness: 400, damping: 30 },
opacity: { duration: 0.15 },
}}
className={cn(
"flex items-center gap-2 h-12 md:h-10 rounded-lg border border-border bg-background px-3 overflow-hidden",
"max-md:w-full",
"md:max-w-100",
)}
>
<input
ref={inputRef}
type="text"
defaultValue=""
autoFocus
onChange={(e) => {
queryRef.current = e.target.value;
}}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="flex-1 bg-transparent text-md md:text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
/>
<button
type="button"
onClick={handleSubmit}
className="shrink-0 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Search
<CornerDownLeft className="size-3 opacity-70" />
</button>
</motion.div>
) : (
<motion.button
key="search-icon"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.12 }}
type="button"
onClick={() => onExpandedChange(true)}
aria-label="Search (press /)"
title="Search (press /)"
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Search className="size-4.5" />
</motion.button>
)}
</div>
);
}
+89 -3
View File
@@ -1,6 +1,13 @@
"use client";
import { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { ReadingSettings } from "@/components/reading-settings"; import { ReadingSettings } from "@/components/reading-settings";
import { HeaderSearchButton } from "@/components/header-search";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SEARCH_RETURN_KEY = "spsays_from_search";
export function PageHeader({ export function PageHeader({
children, children,
sticky = false, sticky = false,
@@ -10,6 +17,29 @@ export function PageHeader({
sticky?: boolean; sticky?: boolean;
className?: string; className?: string;
}) { }) {
const [searchExpanded, setSearchExpanded] = useState(false);
const router = useRouter();
const pathname = usePathname();
// Read (and consume) the search-return flag once on first render.
// The lazy initialiser runs exactly once; on the server
// (typeof window === "undefined") it returns false.
const [fromSearch, setFromSearch] = useState(() => {
if (typeof window === "undefined") return false;
try {
if (
sessionStorage.getItem(SEARCH_RETURN_KEY) === "1" &&
!pathname.startsWith("/search")
) {
sessionStorage.removeItem(SEARCH_RETURN_KEY);
return true;
}
} catch {
// sessionStorage unavailable
}
return false;
});
return ( return (
<header <header
className={cn( className={cn(
@@ -18,12 +48,68 @@ export function PageHeader({
className, className,
)} )}
> >
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4"> {/*
<div className="flex-1 min-w-0">{children}</div> Layout strategy:
<div className="shrink-0 pt-1"> - Mobile: actions float to the right so the title text wraps around
them on the first line and uses the FULL container width on every
subsequent line (which is what `pr-*` cannot achieve since padding
applies to all lines).
- Desktop (md+): standard flex row so title and actions sit side-by-side.
*/}
<div className="max-w-5xl mx-auto md:flex md:items-start md:justify-between md:gap-4">
{/* Actions — float on mobile (rendered first so float works correctly),
inline flex on desktop. Order doesn't matter visually on desktop
because the parent's `justify-between` places them at the end. */}
<div
className={cn(
"flex items-center gap-1.5",
// Desktop: inline at the right of the flex row
"md:pt-1 md:order-1",
// Mobile float (for title wrapping) only applies when collapsed.
// When expanded the search bar fills the full header width and
// should NOT have a left margin or be floated.
searchExpanded
? "max-md:w-full max-md:min-w-0"
: "max-md:float-right max-md:ml-2 md:shrink-0",
)}
>
<HeaderSearchButton
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
{/* Hide reading settings on mobile when search is active */}
<div className={cn(searchExpanded && "max-md:hidden")}>
<ReadingSettings /> <ReadingSettings />
</div> </div>
</div> </div>
{/* Title / logo area — hidden on mobile when search is active */}
<div
className={cn(
"md:flex-1 md:min-w-0",
searchExpanded && "max-md:hidden",
)}
>
{fromSearch ? (
<>
<button
type="button"
onClick={() => {
setFromSearch(false);
router.back();
}}
className="block text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Back to search results
</button>
{/* Hide the page's own breadcrumb link (always the first <a>) */}
<div className="[&>a:first-child]:hidden">{children}</div>
</>
) : (
children
)}
</div>
</div>
</header> </header>
); );
} }
+147 -6
View File
@@ -17,18 +17,88 @@ function useMounted() {
); );
} }
type LightPalette = "aged-parchment" | "tulasi-garden" | "yamuna-mist";
type DarkPalette = "ancient-manuscript" | "nilamani" | "vraja-twilight";
type ReadingPrefs = { type ReadingPrefs = {
serifFont: boolean; serifFont: boolean;
fontSize: string; fontSize: string;
justified: boolean; justified: boolean;
lightPalette: LightPalette;
darkPalette: DarkPalette;
}; };
const DEFAULT_PREFS: ReadingPrefs = { const DEFAULT_PREFS: ReadingPrefs = {
serifFont: false, serifFont: false,
fontSize: "base", fontSize: "base",
justified: false, justified: false,
lightPalette: "aged-parchment",
darkPalette: "ancient-manuscript",
}; };
// Preview swatches use approximations of each palette's background
// and primary colours — the real colours come from the CSS variables.
const LIGHT_PALETTES: {
id: LightPalette;
name: string;
bg: string;
primary: string;
}[] = [
// Warm cream + saffron — the original
{
id: "aged-parchment",
name: "Aged Parchment",
bg: "#f5ecd7",
primary: "#b88940",
},
// Sage green background + deep emerald — distinctly GREEN
{
id: "tulasi-garden",
name: "Tulasī Garden",
bg: "#c4dcc1",
primary: "#207537",
},
// Soft blue background + cerulean — distinctly BLUE
{
id: "yamuna-mist",
name: "Yamunā Mist",
bg: "#c2d7ec",
primary: "#1f6fd6",
},
];
const DARK_PALETTES: {
id: DarkPalette;
name: string;
bg: string;
primary: string;
}[] = [
// Warm brown + saffron — the original
{
id: "ancient-manuscript",
name: "Ancient Manuscript",
bg: "#2a2519",
primary: "#d0a04e",
},
// Deep navy + sky-blue — distinctly BLUE
{
id: "nilamani",
name: "Nīlamaṇi",
bg: "#101a36",
primary: "#4a9fff",
},
// Deep wine + rose-red — distinctly RED/WARM
{
id: "vraja-twilight",
name: "Vraja Twilight",
bg: "#2b1217",
primary: "#e07252",
},
];
const LIGHT_PALETTE_IDS = LIGHT_PALETTES.map((p) => p.id);
const DARK_PALETTE_IDS = DARK_PALETTES.map((p) => p.id);
const FONT_SIZES = [ const FONT_SIZES = [
{ value: "xs", label: "XS" }, { value: "xs", label: "XS" },
{ value: "sm", label: "S" }, { value: "sm", label: "S" },
@@ -58,6 +128,12 @@ function loadPrefs(): ReadingPrefs {
typeof parsed.justified === "boolean" typeof parsed.justified === "boolean"
? parsed.justified ? parsed.justified
: DEFAULT_PREFS.justified, : DEFAULT_PREFS.justified,
lightPalette: LIGHT_PALETTE_IDS.includes(parsed.lightPalette)
? parsed.lightPalette
: DEFAULT_PREFS.lightPalette,
darkPalette: DARK_PALETTE_IDS.includes(parsed.darkPalette)
? parsed.darkPalette
: DEFAULT_PREFS.darkPalette,
}; };
} catch { } catch {
return DEFAULT_PREFS; return DEFAULT_PREFS;
@@ -86,6 +162,11 @@ function applyClasses(prefs: ReadingPrefs) {
// Justified // Justified
root.classList.toggle("reading-justified", prefs.justified); root.classList.toggle("reading-justified", prefs.justified);
// Palettes — always set both attributes so the appropriate one takes
// effect when the user toggles light ↔ dark.
root.setAttribute("data-light-palette", prefs.lightPalette);
root.setAttribute("data-dark-palette", prefs.darkPalette);
} }
function Toggle({ function Toggle({
@@ -128,7 +209,7 @@ export function ReadingSettings() {
const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs()); const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs());
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const { theme, setTheme } = useTheme(); const { theme, setTheme, resolvedTheme } = useTheme();
const mounted = useMounted(); const mounted = useMounted();
// Apply classes on mount (prefs already loaded via lazy initializer) // Apply classes on mount (prefs already loaded via lazy initializer)
@@ -178,6 +259,14 @@ export function ReadingSettings() {
const fontSizeIndex = FONT_SIZES.findIndex((f) => f.value === prefs.fontSize); const fontSizeIndex = FONT_SIZES.findIndex((f) => f.value === prefs.fontSize);
const currentFontSizeLabel = FONT_SIZES[fontSizeIndex]?.label ?? "M"; const currentFontSizeLabel = FONT_SIZES[fontSizeIndex]?.label ?? "M";
// Show palettes matching the currently visible mode. Falls back to light if
// resolvedTheme hasn't been determined yet (e.g. first render).
const isDark = resolvedTheme === "dark";
const visiblePalettes = isDark ? DARK_PALETTES : LIGHT_PALETTES;
const activePaletteId = isDark ? prefs.darkPalette : prefs.lightPalette;
const activePaletteName =
visiblePalettes.find((p) => p.id === activePaletteId)?.name ?? "";
if (!mounted) { if (!mounted) {
return ( return (
<Button variant="ghost" size="icon-sm" aria-label="Reading settings"> <Button variant="ghost" size="icon-sm" aria-label="Reading settings">
@@ -188,16 +277,20 @@ export function ReadingSettings() {
return ( return (
<div className="relative"> <div className="relative">
<Button <button
ref={buttonRef as React.Ref<HTMLButtonElement>} ref={buttonRef as React.Ref<HTMLButtonElement>}
variant="ghost"
size="icon-sm"
aria-label="Reading settings" aria-label="Reading settings"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
> >
<Settings className="size-4" /> <Settings className="size-5 md:size-4" />
</Button> </button>
{open && ( {open && (
<div <div
@@ -235,6 +328,54 @@ export function ReadingSettings() {
</div> </div>
</div> </div>
{/* Palette selector — shows light or dark palettes depending on
the currently resolved theme. */}
<div className="mb-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Palette
</span>
<span className="text-xs text-muted-foreground">
{activePaletteName}
</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-2">
{visiblePalettes.map((p) => {
const isActive = activePaletteId === p.id;
return (
<button
key={p.id}
type="button"
onClick={() =>
updatePrefs(
isDark
? { darkPalette: p.id as DarkPalette }
: { lightPalette: p.id as LightPalette },
)
}
aria-label={`Use ${p.name} palette`}
aria-pressed={isActive}
title={p.name}
className={cn(
"group relative aspect-square rounded-md transition-all cursor-pointer overflow-hidden",
"border-2 outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary ring-2 ring-primary/30"
: "border-border hover:border-foreground/40",
)}
style={{ backgroundColor: p.bg }}
>
{/* Primary-color dot in the corner */}
<span
className="absolute bottom-1.5 right-1.5 size-3 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: p.primary }}
/>
</button>
);
})}
</div>
</div>
<div className="border-t border-border pt-3 space-y-1"> <div className="border-t border-border pt-3 space-y-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Reading Reading
+23 -12
View File
@@ -1,11 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useCopyFeedback, type Position } from "./shared"; import { useCopyFeedback, type Position } from "./shared";
import { buildHighlightUrl } from "@/lib/text-highlight"; import { buildShortHighlightUrl } from "@/lib/text-highlight";
import { cn } from "@/lib/utils"; import { Bookmark, FolderPlus, AtSign, Link2, Check } from "lucide-react";
import { Bookmark, FolderPlus, Link2, AtSign, Check } from "lucide-react";
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -35,6 +34,18 @@ export function MorePopover({
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { copied, copy } = useCopyFeedback(); const { copied, copy } = useCopyFeedback();
const [generatingLink, setGeneratingLink] = useState(false);
async function handleCopyLink() {
if (generatingLink) return;
setGeneratingLink(true);
try {
const url = await buildShortHighlightUrl(selectedText);
await copy(url);
} finally {
setGeneratingLink(false);
}
}
useEffect(() => { useEffect(() => {
if (!open || isMobile) return; if (!open || isMobile) return;
@@ -71,14 +82,14 @@ export function MorePopover({
}, },
{ {
key: "copy-link", key: "copy-link",
label: copied ? "Link Copied!" : "Copy Link", label: copied
description: "Copy a link to this page", ? "Link Copied!"
icon: copied ? ( : generatingLink
<Check className="size-5" /> ? "Generating link…"
) : ( : "Copy Link",
<Link2 className="size-5" /> description: "Copy a shareable link to this quote",
), icon: copied ? <Check className="size-5" /> : <Link2 className="size-5" />,
onClick: () => copy(buildHighlightUrl(selectedText)), onClick: handleCopyLink,
}, },
{ {
key: "mention", key: "mention",
+24 -6
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState } from "react";
import { useCopyFeedback } from "./shared"; import { useCopyFeedback } from "./shared";
import { buildHighlightUrl } from "@/lib/text-highlight"; import { buildShortHighlightUrl } from "@/lib/text-highlight";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X, MessageSquare, ExternalLink } from "lucide-react"; import { X, MessageSquare, ExternalLink } from "lucide-react";
import { import {
@@ -24,6 +25,18 @@ export function ShareModal({
isMobile: boolean; isMobile: boolean;
}) { }) {
const { copied, copy } = useCopyFeedback(); const { copied, copy } = useCopyFeedback();
const [generating, setGenerating] = useState(false);
async function handleCopyShareLink() {
if (generating) return;
setGenerating(true);
try {
const url = await buildShortHighlightUrl(selectedText);
await copy(url);
} finally {
setGenerating(false);
}
}
const shareOptions = [ const shareOptions = [
{ {
@@ -41,12 +54,17 @@ export function ShareModal({
label: "Share Link", label: "Share Link",
icon: <ExternalLink className="size-4" />, icon: <ExternalLink className="size-4" />,
description: "Copy a shareable link", description: "Copy a shareable link",
onClick: () => { onClick: handleCopyShareLink,
copy(buildHighlightUrl(selectedText));
},
}, },
]; ];
function linkLabel(optKey: string, optLabel: string) {
if (optKey !== "link") return optLabel;
if (copied) return "Link Copied!";
if (generating) return "Generating link…";
return optLabel;
}
if (isMobile) { if (isMobile) {
return ( return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}> <Drawer open={open} onOpenChange={(o) => !o && onClose()}>
@@ -72,7 +90,7 @@ export function ShareModal({
</div> </div>
<div> <div>
<p className="text-sm font-medium text-white/90"> <p className="text-sm font-medium text-white/90">
{opt.key === "link" && copied ? "Link Copied!" : opt.label} {linkLabel(opt.key, opt.label)}
</p> </p>
<p className="text-xs text-white/50">{opt.description}</p> <p className="text-xs text-white/50">{opt.description}</p>
</div> </div>
@@ -130,7 +148,7 @@ export function ShareModal({
</div> </div>
<div> <div>
<p className="text-sm font-medium text-white/90"> <p className="text-sm font-medium text-white/90">
{opt.key === "link" && copied ? "Link Copied!" : opt.label} {linkLabel(opt.key, opt.label)}
</p> </p>
<p className="text-xs text-white/50">{opt.description}</p> <p className="text-xs text-white/50">{opt.description}</p>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@ export function SiteFooter() {
<footer className="border-t border-border bg-card mt-auto"> <footer className="border-t border-border bg-card mt-auto">
<div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground"> <div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<p> <p>
&copy; {new Date().getFullYear()}{" "} Team {" "}
<span className="font-medium text-foreground">Prabhupāda Says</span> <span className="font-medium text-foreground">Prabhupāda Says</span>
</p> </p>
+222 -127
View File
@@ -1,13 +1,11 @@
/** /**
* Data access layer for server components. * Data access layer for server components.
* *
* These functions query MongoDB via Prisma and are called directly by * These functions call the Go BFF API server instead of querying the DB
* Next.js server components. The /api/* routes exist for external consumers * directly. The BFF handles sanitization, pagination, and caching.
* and for client-side verse-navigation fetching.
*/ */
import { prisma } from "@/lib/db"; import { bff, bffList, bffOrThrow } from "@/lib/bff";
import type { JsonValue } from "@prisma/client/runtime/library";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -33,6 +31,12 @@ export type VerseRecord = {
synonyms: { word: string; meanings: string[] }[]; synonyms: { word: string; meanings: string[] }[];
translation: string; translation: string;
purport: string[]; purport: string[];
/** Alias for bookTitle — used by ISO/BS/NOI pages */
title?: string;
/** Alias for verse number — used by NOI pages as "text number" */
text?: number;
/** Alias for verse/chapter number — used by ISO pages as "mantra number" */
mantra?: number;
}; };
export type VerseIndexEntry = { export type VerseIndexEntry = {
@@ -73,28 +77,133 @@ export type ProseChapterRecord = {
paragraphs: string[]; paragraphs: string[];
}; };
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
function sanitizeParagraphs(paragraphs: string[] | undefined): string[] {
if (!paragraphs || !Array.isArray(paragraphs)) return [];
return paragraphs.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a BFF path for verse-related endpoints.
* Books like BG and SB1962 have no division (null), so we skip it.
* Books like SB and CC have a division (canto / līlā).
*/
function versePath(
book: string,
division: string | null,
chapter: number,
): string {
if (division) return `/books/${book}/${division}/${chapter}`;
return `/books/${book}/${chapter}`;
}
/**
* Map a BFF verse response (snake_case) to the frontend VerseRecord (camelCase).
*/
function mapVerse(v: Record<string, unknown>): VerseRecord {
const bookTitle = String(v.book_title ?? v.bookTitle ?? "");
const chapter = Number(v.chapter ?? 0);
const verse = Number(v.verse ?? 0);
return {
id: String(v.id ?? ""),
book: String(v.book ?? ""),
division: v.division ? String(v.division) : null,
chapter,
verse,
verseEnd:
v.verse_end != null
? Number(v.verse_end)
: v.verseEnd != null
? Number(v.verseEnd)
: null,
reference: String(v.reference ?? ""),
bookTitle,
devanagari: String(v.devanagari ?? ""),
bengali: v.bengali ? String(v.bengali) : null,
transliteration: String(v.transliteration ?? ""),
synonyms: (v.synonyms as { word: string; meanings: string[] }[]) ?? [],
translation: String(v.translation ?? ""),
purport: (v.purport as string[]) ?? [],
// Aliases for page backwards compatibility
title: bookTitle,
text: verse,
mantra: chapter,
};
}
/**
* Map a BFF verse-index entry (snake_case) to VerseIndexEntry (camelCase).
*/
function mapVerseIndex(v: Record<string, unknown>): VerseIndexEntry {
return {
verse: Number(v.verse ?? 0),
verseEnd: v.verse_end ? Number(v.verse_end) : null,
reference: String(v.reference ?? ""),
};
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Book meta // Book meta
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Get all book metas — each has { book, ...data } where data is the intro.json blob. * Get all book metas — each has { book, ...data } where data is the intro.json blob.
*
* The BFF returns { id, book, data: { title, author, chapters, ... } }.
* We flatten it to { book, title, author, chapters, ... } for the frontend.
*/ */
export async function getAllBooks(): Promise<BookMeta[]> { export async function getAllBooks(): Promise<BookMeta[]> {
const metas = await prisma.bookMeta.findMany(); const raw = await bffList<{ book: string; data: Record<string, unknown> }>(
return metas.map((m) => ({ "/books",
book: m.book, { tags: ["books"] },
...(m.data as Record<string, unknown>), );
})); return raw.map(flattenBookMeta);
} }
/** /**
* Get a single book's meta by slug ("bg", "sb", "cc"). * Get a single book's meta by slug ("bg", "sb", "cc").
*/ */
export async function getBookMeta(book: string): Promise<BookMeta | null> { export async function getBookMeta(book: string): Promise<BookMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book } }); const raw = await bff<{ book: string; data: Record<string, unknown> }>(
if (!meta) return null; `/books/${book}`,
return { book: meta.book, ...(meta.data as Record<string, unknown>) }; { tags: [`book-${book}`] },
);
if (!raw) return null;
return flattenBookMeta(raw);
}
/**
* Flatten a BFF book meta response.
* BFF returns: { id, book, data: { title, author, chapters, ... } }
* Frontend expects: { book, title, author, chapters, ... }
*/
function flattenBookMeta(raw: {
book: string;
data: Record<string, unknown>;
}): BookMeta {
const data = raw.data && typeof raw.data === "object" ? raw.data : {};
return { book: raw.book, ...data } as BookMeta;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -115,17 +224,14 @@ export async function getChapterVerseIndex(
division: string | null, division: string | null,
chapter: number, chapter: number,
): Promise<VerseIndexEntry[]> { ): Promise<VerseIndexEntry[]> {
const entries = await prisma.verse.findMany({ const path = versePath(book, division, chapter);
where: { book, division, chapter }, const res = await bff<{
orderBy: { verse: "asc" }, verses?: Record<string, unknown>[];
select: { }>(path, {
verse: true, tags: [`verses-${book}`],
verseEnd: true,
reference: true,
},
}); });
if (!res || !res.verses) return [];
return entries; return res.verses.map(mapVerseIndex);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -147,27 +253,41 @@ export async function getChapterVerses(
page: number = 1, page: number = 1,
limit: number = DEFAULT_PAGE_SIZE, limit: number = DEFAULT_PAGE_SIZE,
): Promise<PaginatedVerses> { ): Promise<PaginatedVerses> {
const skip = (page - 1) * limit; const path = versePath(book, division, chapter);
const where = { book, division, chapter }; const raw = await bff<{
verses: Record<string, unknown>[];
const [verses, total] = await Promise.all([ total: number;
prisma.verse.findMany({ page: number;
where, limit: number;
orderBy: { verse: "asc" }, totalPages: number;
skip, hasMore: boolean;
take: limit, }>(`${path}/verses`, {
}), params: {
prisma.verse.count({ where }), page: String(page),
]); limit: String(limit),
},
tags: [`verses-${book}`],
});
if (!raw) {
return { return {
verses: verses as unknown as VerseRecord[], verses: [],
total, total: 0,
page, page,
limit, limit,
totalPages: Math.ceil(total / limit), totalPages: 0,
hasMore: skip + verses.length < total, hasMore: false,
};
}
return {
verses: (raw.verses ?? []).map(mapVerse),
total: raw.total,
page: raw.page,
limit: raw.limit,
totalPages: raw.totalPages,
hasMore: raw.hasMore,
}; };
} }
@@ -184,11 +304,11 @@ export async function getSingleVerse(
chapter: number, chapter: number,
verse: number, verse: number,
): Promise<VerseRecord | null> { ): Promise<VerseRecord | null> {
const result = await prisma.verse.findFirst({ const path = versePath(book, division, chapter);
where: { book, division, chapter, verse }, const raw = await bff<Record<string, unknown>>(`${path}/${verse}`, {
tags: [`verses-${book}`],
}); });
return raw ? mapVerse(raw) : null;
return result as unknown as VerseRecord | null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -199,109 +319,76 @@ export async function getSingleVerse(
* Get all prose chapters/segments for a given book and volume, * Get all prose chapters/segments for a given book and volume,
* optionally filtered by type ("chapter" | "intro-segment"). * optionally filtered by type ("chapter" | "intro-segment").
* Ordered by the `order` field. * Ordered by the `order` field.
*
* For KB (which has real volumes), uses /api/v1/books/kb/{volume}.
* For other books (volume=0 sentinel), uses /api/v1/books/{code}/chapters.
*/ */
export async function getProseChapters( export async function getProseChapters(
book: string, book: string,
volume: number, volume: number,
type?: string, type?: string,
): Promise<ProseChapterRecord[]> { ): Promise<ProseChapterRecord[]> {
const where: Record<string, unknown> = { book, volume }; // KB has real volumes (1, 2, 3, …); other prose books use volume=0 as sentinel
if (type) where.type = type; const path =
book === "kb" && volume > 0
? `/books/kb/${volume}`
: `/books/${book}/chapters`;
const results = await prisma.proseChapter.findMany({ const params: Record<string, string> = {};
where, if (type) params.type = type;
orderBy: { order: "asc" },
const chapters = await bffList<ProseChapterRecord>(path, {
params: Object.keys(params).length > 0 ? params : undefined,
tags: [`book-${book}`],
}); });
return chapters.map((ch) => ({
return results.map(normalizeProse); ...ch,
paragraphs: sanitizeParagraphs(ch.paragraphs),
id: ch.id ?? "",
book: ch.book ?? book,
volume: ch.volume ?? volume,
chapter: ch.chapter ?? null,
title: ch.title ?? "",
type: ch.type ?? "chapter",
slug: ch.slug ?? "",
order: ch.order ?? 0,
}));
} }
/** /**
* Get a single prose chapter by book + volume + slug. * Get a single prose chapter by book + volume + slug.
*
* For KB, uses /api/v1/books/kb/{volume}/{slug}.
* For other books, uses /api/v1/books/{code}/chapters/{slug}.
*/ */
export async function getProseChapter( export async function getProseChapter(
book: string, book: string,
volume: number, volume: number,
slug: string, slug: string,
): Promise<ProseChapterRecord | null> { ): Promise<ProseChapterRecord | null> {
const result = await prisma.proseChapter.findUnique({ const path =
where: { book === "kb" && volume > 0
book_volume_slug: { book, volume, slug }, ? `/books/kb/${volume}/${slug}`
}, : `/books/${book}/chapters/${slug}`;
const chapter = await bff<ProseChapterRecord>(path, {
tags: [`book-${book}`],
}); });
if (!chapter) return null;
return result ? normalizeProse(result) : null;
}
/**
* Normalize a prose chapter from the DB.
* Some chapters were stored with paragraphs as a JSON string (fallback
* for BSON wire-protocol corruption). This normalizes them back to arrays.
*/
function normalizeProse(doc: {
id: string;
book: string;
volume: number;
chapter: number | null;
title: string;
type: string;
slug: string;
order: number;
paragraphs: string[] | JsonValue;
}): ProseChapterRecord {
let paragraphs: string[];
if (typeof doc.paragraphs === "string") {
// Was stored as stringified JSON — parse it back
try {
paragraphs = JSON.parse(doc.paragraphs);
} catch {
paragraphs = [doc.paragraphs];
}
} else if (Array.isArray(doc.paragraphs)) {
paragraphs = doc.paragraphs as string[];
} else {
paragraphs = [];
}
// Sanitize HTML — strip dangerous tags and event handlers
paragraphs = paragraphs.map(sanitizeHtml);
return { return {
id: doc.id, ...chapter,
book: doc.book, paragraphs: sanitizeParagraphs(chapter.paragraphs),
volume: doc.volume, id: chapter.id ?? "",
chapter: doc.chapter, book: chapter.book ?? book,
title: doc.title, volume: chapter.volume ?? volume,
type: doc.type, chapter: chapter.chapter ?? null,
slug: doc.slug, title: chapter.title ?? "",
order: doc.order, type: chapter.type ?? "chapter",
paragraphs, slug: chapter.slug ?? slug,
order: chapter.order ?? 0,
}; };
} }
/**
* Sanitize an HTML string by stripping dangerous tags and attributes.
* Keeps safe formatting tags: <i>, <b>, <em>, <strong>, <br>, <div>, <span>.
*/
function sanitizeHtml(html: string): string {
// Remove <script>...</script>, <style>...</style>, <iframe>...</iframe>
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, "");
// Remove lone <script>, <style>, <iframe> opening/closing tags
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
// Remove event handler attributes (onclick, onload, onerror, etc.)
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
// Remove javascript: URLs
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Glossary // Glossary
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -310,9 +397,17 @@ function sanitizeHtml(html: string): string {
* Get all glossary entries, ordered alphabetically by term. * Get all glossary entries, ordered alphabetically by term.
*/ */
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> { export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
const entries = await prisma.glossaryEntry.findMany({ const raw = await bffList<{
orderBy: { term: "asc" }, id: string;
}); term: string;
definition: string;
definition_html: string;
}>("/glossary", { tags: ["glossary"] });
return entries as unknown as GlossaryRecord[]; return raw.map((entry) => ({
id: entry.id,
term: entry.term,
definition: entry.definition,
definitionHtml: entry.definition_html ?? entry.definition,
}));
} }
+26 -249
View File
@@ -1,280 +1,57 @@
/** /**
* Data access layer for Beyond Birth and Death (BBD). * Data access layer for Beyond Birth and Death (BBD).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* BBD is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "bbd";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "bbd"; // Re-export types with book-specific names for backwards compatibility
export type BbdMeta = BookMeta;
export type BbdChapterEntry = { number: number; title: string };
export type BbdIntroSegment = IntroSegment;
export type BbdChapter = Chapter;
export type BbdSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BbdMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: BbdChapterEntry[];
};
export type BbdChapterEntry = {
number: number;
title: string;
};
export type BbdIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type BbdChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type BbdSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getBbdMeta(): Promise<BbdMeta | null> { export async function getBbdMeta(): Promise<BbdMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); return getBookMeta(CODE);
if (!meta) return null;
return meta.data as unknown as BbdMeta;
} }
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getBbdIntroSegments(): Promise<BbdIntroSegment[]> { export async function getBbdIntroSegments(): Promise<BbdIntroSegment[]> {
const results = await prisma.proseChapter.findMany({ return getIntroSegments(CODE);
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getBbdIntroSegment( export async function getBbdIntroSegment(
slug: string, slug: string,
): Promise<BbdIntroSegment | null> { ): Promise<BbdIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form return getIntroSegment(CODE, slug);
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–5).
*/
export async function getBbdChapter( export async function getBbdChapter(
chapterNum: number, chapterNum: number,
): Promise<BbdChapter | null> { ): Promise<BbdChapter | null> {
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) { return getChapter(CODE, String(chapterNum));
return null;
} }
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "5" → chapter
*/
export async function getBbdContent( export async function getBbdContent(
slug: string, slug: string,
): Promise<BbdChapter | BbdIntroSegment | null> { ): Promise<BbdChapter | BbdIntroSegment | null> {
if (slug.startsWith("intro-")) { return getContent(CODE, slug);
return getBbdIntroSegment(slug);
} }
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
return getBbdChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–5
*/
export async function getBbdSidebarItems(): Promise<BbdSidebarItem[]> { export async function getBbdSidebarItems(): Promise<BbdSidebarItem[]> {
const [meta, allDocs] = await Promise.all([ return getSidebarItems(CODE);
getBbdMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: BbdSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–5 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
} }
+161
View File
@@ -0,0 +1,161 @@
/**
* BFF API client for server components.
*
* Provides typed fetch helpers that call the Go BFF API server.
* Used by all data layer files (lib/*.ts) instead of direct Prisma queries.
*
* The BFF URL is configured via the BFF_URL environment variable.
*/
const BFF_BASE = process.env.BFF_URL || "http://localhost:8080";
/** Build a full BFF API URL. */
function apiUrl(path: string, params?: Record<string, string>): string {
const url = new URL(`/api/v1${path}`, BFF_BASE);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
/** Custom error for BFF API failures. */
export class BffError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "BffError";
}
}
/**
* Fetch JSON from the BFF API.
*
* - Throws BffError on non-2xx responses
* - Returns null for 404 responses (instead of throwing)
* - Uses Next.js fetch with revalidation options
*
* @param path - API path (e.g., "/books/bg")
* @param options - Optional fetch options
* @param params - Optional query parameters
*/
export async function bff<T>(
path: string,
options?: {
params?: Record<string, string>;
/** Next.js revalidation in seconds. Default: 60. Set to 0 for no cache. */
revalidate?: number;
/** Next.js cache tags for on-demand revalidation */
tags?: string[];
},
): Promise<T | null> {
const url = apiUrl(path, options?.params);
const res = await fetch(url, {
next: {
revalidate: options?.revalidate ?? 60,
tags: options?.tags,
},
headers: {
Accept: "application/json",
},
});
if (res.status === 404) {
return null;
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new BffError(
res.status,
`BFF ${res.status} ${path}: ${body.slice(0, 200)}`,
);
}
const json = await res.json();
// Unwrap BFF envelope: { success: true, data: T }
if (json && typeof json === "object" && "data" in json) {
return json.data as T;
}
// Fallback: return raw response if no envelope
return json as T;
}
/**
* Fetch JSON from the BFF API, throwing on 404.
* Use this when a missing resource should be an error, not null.
*/
export async function bffOrThrow<T>(
path: string,
options?: {
params?: Record<string, string>;
revalidate?: number;
tags?: string[];
},
): Promise<T> {
const result = await bff<T>(path, options);
if (result === null) {
throw new BffError(404, `Not found: ${path}`);
}
return result;
}
/**
* Fetch a list from the BFF API.
* Returns empty array on 404 (instead of null).
*/
export async function bffList<T>(
path: string,
options?: {
params?: Record<string, string>;
revalidate?: number;
tags?: string[];
},
): Promise<T[]> {
const result = await bff<T[]>(path, options);
return result ?? [];
}
/**
* POST JSON to the BFF API. Used for mutations like the URL shortener; does
* not participate in Next.js caching (each POST is a unique side-effect).
*
* Same envelope-unwrapping behavior as bff(): returns the inner `data`
* value, throws BffError on non-2xx.
*/
export async function bffPost<T>(
path: string,
body: Record<string, unknown>,
): Promise<T> {
const url = apiUrl(path);
const res = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new BffError(
res.status,
`BFF ${res.status} POST ${path}: ${text.slice(0, 200)}`,
);
}
const json = await res.json();
if (json && typeof json === "object" && "data" in json) {
return json.data as T;
}
return json as T;
}
+22 -292
View File
@@ -1,299 +1,29 @@
/** import {
* Data access layer for Bhagavāner Kathā (BK). getBookMeta,
* getChapter,
* Reads from MongoDB via Prisma, same as the rest of the app. getContent,
* BK is a simple prose book with no volume structure and no intro segments — getSidebarItems,
* just 2 chapters directly. We use volume=0 as the sentinel for "no volumes". type BookMeta,
* type Chapter,
* When the DB isn't seeded yet, falls back to reading local JSON files type SidebarItem,
* from lib/jsons/books/bk/. } from "@/lib/books";
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db"; const CODE = "bk";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// --------------------------------------------------------------------------- export type BkMeta = BookMeta;
// Constants export type BkChapterEntry = { number: number; title: string };
// --------------------------------------------------------------------------- export type BkChapter = Chapter;
export type BkSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */ export async function getBkMeta() {
const NO_VOLUME = 0; return getBookMeta(CODE);
const BOOK = "bk";
const BK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BkMeta = {
book: string;
title: string;
author: string;
year?: number | null;
description: string;
totalChapters: number;
chapters: BkChapterEntry[];
};
export type BkChapterEntry = {
number: number;
title: string;
};
export type BkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type BkSidebarItem = {
/** URL-safe slug: "1", "2" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "chapter" */
type: string;
/** Chapter number */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getBkChapter(chapterNum: number) {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getChapter(CODE, String(chapterNum));
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getBkContent(slug: string) {
result = paragraphs as string[]; return getContent(CODE, slug);
} else {
result = [];
} }
export async function getBkSidebarItems() {
return result.map(sanitizeHtml); return getSidebarItems(CODE);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalChapter(chapterNum: number): BkChapter | null {
const filePath = path.join(
BK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<BkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getBkMeta(): Promise<BkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as BkMeta;
// Fallback to local JSON
const introPath = path.join(BK_JSON_DIR, "intro.json");
return readLocalJson<BkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–2).
*/
export async function getBkChapter(
chapterNum: number,
): Promise<BkChapter | null> {
if (chapterNum < 1 || chapterNum > 2 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "1" … "2" → chapter
*/
export async function getBkContent(
slug: string,
): Promise<BkChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
return getBkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–2 (no intro segments)
*/
export async function getBkSidebarItems(): Promise<BkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getBkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(meta: BkMeta): BkSidebarItem[] {
const items: BkSidebarItem[] = [];
// Chapters 1–2 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: BkMeta | null): BkSidebarItem[] {
const items: BkSidebarItem[] = [];
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(BK_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<BkChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
} }
+398
View File
@@ -0,0 +1,398 @@
/**
* Generic book data layer — replaces individual book files (bbd.ts, ej.ts, etc.)
*
* Calls the BFF API for book metadata, chapters, and intro segments.
* All prose books share the same BFF endpoints:
* GET /api/v1/books/{code} → book meta
* GET /api/v1/books/{code}/chapters → all chapters (with optional ?type= filter)
* GET /api/v1/books/{code}/chapters/{slug} → single chapter
*/
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types — generic, work for any prose book
// ---------------------------------------------------------------------------
export type BookMeta = {
book: string;
title: string;
author: string;
year?: number;
subtitle?: string;
description?: string;
totalChapters?: number;
chapters: ChapterEntry[];
/** Volume info for multi-volume books like KB */
volumes?: { number: number; title: string; chapters: ChapterEntry[] }[];
/** Parent chapters for hierarchical books (SSR, DS, SVA, NOD) — always [] for non-hierarchical books */
parts: {
number: number;
title: string;
subChapters: { slug: string; title: string }[];
/** NOD-style: array of chapter numbers belonging to this part */
chapters: number[];
}[];
/** For ISO — mantra entries (always [] for non-ISO books) */
mantras: { number: number; slug: string; title: string }[];
/** For BS/NOI — text entries (always [] for non-BS/NOI books) */
texts: { number: number; slug: string; title: string }[];
/** Total mantras (ISO) */
totalMantras?: number;
/** Total texts (BS, NOI) */
totalTexts?: number;
};
export type ChapterEntry = {
number: number;
title: string;
slug?: string;
subChapters: { slug: string; title: string }[];
};
export type IntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type Chapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug: string;
order: number;
paragraphs: string[];
/** For hierarchical books (SSR, DS, SVA) — the parent chapter number */
parentChapter?: number;
/** For hierarchical sub-chapters — the sub-chapter number */
subChapter?: number;
};
export type SidebarItem = {
slug: string;
label: string;
type: string;
chapter: number | null;
/** For hierarchical books (SSR, DS, SVA) — the parent chapter number */
parentChapter?: number;
/** Sub-chapters within a parent chapter */
subChapters?: { slug: string; title: string }[];
/** For verse books (ISO) — mantra number */
mantra?: number;
/** For verse books (BS, NOI) — text number */
text?: number;
/** Part number this item belongs to (for books with parts like TLC, NOD) */
partNumber?: number;
/** Part title (e.g., "Part I", "Part One") */
partTitle?: string;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
// ---------------------------------------------------------------------------
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
type BffChapter = {
id: string;
book: string;
volume: number;
chapter: number | null;
title: string;
type: string;
slug: string;
order: number;
paragraphs: string[];
mantra?: number;
text?: number;
};
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
function mapChapter(c: BffChapter): Chapter {
return {
book: c.book,
chapter: c.chapter,
title: c.title,
type: c.type,
slug: c.slug,
order: c.order,
paragraphs: (c.paragraphs ?? []).map(sanitizeHtml),
};
}
function mapIntro(c: BffChapter): IntroSegment {
return {
slug: c.slug,
title: c.title,
type: c.type,
book: c.book,
order: c.order,
paragraphs: (c.paragraphs ?? []).map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Generic book functions
// ---------------------------------------------------------------------------
/**
* Get book metadata.
*/
export async function getBookMeta(code: string): Promise<BookMeta | null> {
const raw = await bff<{ book: string; data: Record<string, unknown> }>(
`/books/${code}`,
{ tags: [`book-${code}`] },
);
if (!raw) return null;
// Flatten: BFF returns { id, book, data: { title, chapters, parts, ... } }
const data = raw.data && typeof raw.data === "object" ? raw.data : {};
const meta = { book: raw.book, ...data } as BookMeta;
// Ensure arrays are always present
if (!meta.chapters) meta.chapters = [];
for (const ch of meta.chapters) {
if (!ch.subChapters) ch.subChapters = [];
}
if (!meta.mantras) meta.mantras = [];
if (!meta.texts) meta.texts = [];
if (!meta.parts) meta.parts = [];
for (const part of meta.parts) {
if (!part.subChapters) part.subChapters = [];
if (!part.chapters) part.chapters = [];
}
return meta;
}
/**
* Get all intro segments for a book.
*/
export async function getIntroSegments(code: string): Promise<IntroSegment[]> {
const chapters = await bffList<BffChapter>(`/books/${code}/chapters`, {
tags: [`book-${code}`],
});
return chapters.filter((c) => c.type === "intro-segment").map(mapIntro);
}
/**
* Get a single intro segment by slug.
*/
export async function getIntroSegment(
code: string,
slug: string,
): Promise<IntroSegment | null> {
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const chapter = await bff<BffChapter>(`/books/${code}/chapters/${dbSlug}`, {
tags: [`book-${code}`],
});
return chapter ? mapIntro(chapter) : null;
}
/**
* Get a single chapter by slug (numeric or named).
*/
export async function getChapter(
code: string,
slug: string,
): Promise<Chapter | null> {
const chapter = await bff<BffChapter>(`/books/${code}/chapters/${slug}`, {
tags: [`book-${code}`],
});
return chapter ? mapChapter(chapter) : null;
}
/**
* Get content by URL slug — routes to intro segment or chapter.
*/
export async function getContent(
code: string,
slug: string,
): Promise<Chapter | IntroSegment | null> {
if (slug.startsWith("intro-")) {
return getIntroSegment(code, slug);
}
// Try as chapter
return getChapter(code, slug);
}
/**
* Build sidebar navigation items for a book.
* Returns intro segments first, then chapters.
*/
export async function getSidebarItems(code: string): Promise<SidebarItem[]> {
const [meta, allChapters] = await Promise.all([
getBookMeta(code),
bffList<BffChapter>(`/books/${code}/chapters`, { tags: [`book-${code}`] }),
]);
if (!meta) return [];
const items: SidebarItem[] = [];
// Intro segments
for (const ch of allChapters) {
if (ch.type === "intro-segment") {
items.push({
slug: ch.slug,
label: ch.title,
type: "intro-segment",
chapter: null,
});
}
}
// Verse entries (mantras for ISO, texts for BS/NOI)
for (const ch of allChapters) {
if (ch.type === "verse") {
if (ch.mantra != null) {
items.push({
slug: ch.slug,
label: ch.mantra === 0 ? "Invocation" : `Mantra ${ch.mantra}`,
type: "verse",
chapter: null,
mantra: ch.mantra,
});
} else if (ch.text != null) {
items.push({
slug: ch.slug,
label: `Text ${ch.text}`,
type: "verse",
chapter: null,
text: ch.text,
});
}
}
}
// Hierarchical books (SVA, SSR, DS) — chapters have nested subChapters
const hasHierarchical =
meta.chapters &&
meta.chapters.length > 0 &&
meta.chapters.some((ch) => ch.subChapters && ch.subChapters.length > 0);
if (hasHierarchical && meta.chapters) {
for (const ch of meta.chapters) {
// Parent chapter heading (not navigable on its own)
items.push({
slug: `parent-${ch.number}`,
label: `${ch.number}. ${ch.title}`,
type: "parent",
chapter: ch.number,
parentChapter: ch.number,
});
// Sub-chapters
if (ch.subChapters) {
for (const sub of ch.subChapters) {
items.push({
slug: sub.slug,
label: sub.title,
type: "sub-chapter",
chapter: ch.number,
parentChapter: ch.number,
});
}
}
}
}
// Chapters — if parts exist, group chapters under part headers
else if (
meta.parts &&
meta.parts.length > 0 &&
meta.chapters &&
meta.chapters.length > 0
) {
// Build a lookup: chapter number → which part it belongs to
const chapterToPart = new Map<
number,
{ partNumber: number; partTitle: string }
>();
for (const part of meta.parts) {
for (const chNum of part.chapters) {
chapterToPart.set(chNum, {
partNumber: part.number,
partTitle: part.title,
});
}
}
for (const ch of meta.chapters) {
const partInfo = chapterToPart.get(ch.number);
items.push({
slug: ch.slug ?? String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
partNumber: partInfo?.partNumber,
partTitle: partInfo?.partTitle,
});
}
} else if (meta.chapters && meta.chapters.length > 0) {
// No parts — flat chapter list
for (const ch of meta.chapters) {
items.push({
slug: ch.slug ?? String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: use chapters from the DB ordered by order field
for (const ch of allChapters) {
if (ch.type === "chapter") {
items.push({
slug: ch.slug,
label: ch.title,
type: "chapter",
chapter: ch.chapter,
});
}
}
}
// Conclusion segments (if any)
for (const ch of allChapters) {
if (ch.type === "conclusion") {
items.push({
slug: ch.slug,
label: ch.title,
type: "conclusion",
chapter: null,
});
}
}
return items;
}
/**
* For hierarchical books (SSR, DS) — get the navigable items
* (intro segments + parent chapters that have subChapters).
* This matches the getNavigableItems() functions in ssr.ts and ds.ts.
*/
export function getNavigableItems(items: SidebarItem[]): SidebarItem[] {
return items;
}
+46 -333
View File
@@ -1,357 +1,70 @@
/** /**
* Data access layer for Śrī Brahma-saṁhitā (BS). * Data access layer for Śrī Brahma-saṁhitā (BS).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer (BFF API) for prose content,
* BS has BOTH verses (in the `verses` collection) * and to lib/api.ts for verse lookups.
* AND prose intro segments (in the `prose_chapters` collection).
* *
* Verses were seeded with: * BS has 62 texts (verses) plus intro segments.
* - book: "bs", division: null * Verses are stored with: book "bs", division null, chapter 5, verse = text number.
* - chapter = text number (1-62)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "bs", volume: 0, type: "intro-segment"
* - slugs like "intro-introduction", "intro-foreword"
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { getSingleVerse, type VerseRecord } from "@/lib/api";
const CODE = "bs";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Re-export types with book-specific names for backwards compatibility
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const BOOK = "bs"; export type BsMeta = BookMeta;
const NO_VOLUME = 0; export type BsIntroSegment = IntroSegment;
export type BsChapter = Chapter;
export type BsSidebarItem = SidebarItem;
export type BsVerse = VerseRecord;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Text title lookup // Data accessors
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TEXT_TITLES: Record<number, string> = { export async function getBsMeta() {
1: "Text 1", return getBookMeta(CODE);
2: "Text 2", }
3: "Text 3", export async function getBsIntroSegments() {
4: "Text 4", return getIntroSegments(CODE);
5: "Text 5", }
6: "Text 6", export async function getBsIntroSegment(slug: string) {
7: "Text 7", return getIntroSegment(CODE, slug);
8: "Text 8",
9: "Text 9",
10: "Text 10",
11: "Text 11",
12: "Text 12",
13: "Text 13",
14: "Text 14",
15: "Text 15",
16: "Text 16",
17: "Text 17",
18: "Text 18",
19: "Text 19",
20: "Text 20",
21: "Text 21",
22: "Text 22",
23: "Text 23",
24: "Text 24",
25: "Text 25",
26: "Text 26",
27: "Text 27",
28: "Text 28",
29: "Text 29",
30: "Text 30",
31: "Text 31",
32: "Text 32",
33: "Text 33",
34: "Text 34",
35: "Text 35",
36: "Text 36",
37: "Text 37",
38: "Text 38",
39: "Text 39",
40: "Text 40",
41: "Text 41",
42: "Text 42",
43: "Text 43",
44: "Text 44",
45: "Text 45",
46: "Text 46",
47: "Text 47",
48: "Text 48",
49: "Text 49",
50: "Text 50",
51: "Text 51",
52: "Text 52",
53: "Text 53",
54: "Text 54",
55: "Text 55",
56: "Text 56",
57: "Text 57",
58: "Text 58",
59: "Text 59",
60: "Text 60",
61: "Text 61",
62: "Text 62",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BsMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalTexts: number;
texts: BsTextEntry[];
};
export type BsTextEntry = {
number: number;
title: string;
slug: string;
};
export type BsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type BsVerse = {
book: string;
book_title: string;
text: number; // 1-62
title: string;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type BsSidebarItem = {
/** URL-safe slug: "intro-introduction", "intro-foreword", "1" … "62" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Text number for verses, or null for intro segments */
text: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/iso.ts)
// ---------------------------------------------------------------------------
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;
} }
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
function normalizePurport(purport: string[] | JsonValue): string[] {
return normalizeParagraphs(purport);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, texts list, etc.)
*/
export async function getBsMeta(): Promise<BsMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as BsMeta;
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getBsIntroSegments(): Promise<BsIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getBsIntroSegment(
slug: string,
): Promise<BsIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Verses (from verses collection)
// ---------------------------------------------------------------------------
/**
* A single verse/text by slug.
*
* - "1"-"62" → chapter=1–62, verse=1
*
* Verse stored as chapter=text_number, verse=1 in the verses collection.
*/
export async function getBsVerse(slug: string): Promise<BsVerse | null> { export async function getBsVerse(slug: string): Promise<BsVerse | null> {
const num = Number(slug); const num = parseInt(slug, 10);
if (!Number.isInteger(num) || num < 1 || num > 62) return null; if (isNaN(num)) return null;
const textNum = num; return getSingleVerse(CODE, null, 5, num);
const doc = await prisma.verse.findFirst({
where: { book: BOOK, division: null, chapter: textNum, verse: 1 },
});
if (!doc) return null;
return {
book: doc.book,
book_title: doc.bookTitle,
text: doc.chapter, // chapter field stores the text number
title: TEXT_TITLES[doc.chapter] ?? `Text ${doc.chapter}`,
reference: doc.reference,
devanagari: doc.devanagari,
transliteration: doc.transliteration,
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
translation: sanitizeHtml(doc.translation),
purport: normalizePurport(doc.purport),
};
} }
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-introduction" → intro segment
* "intro-foreword" → intro segment
* "1" … "62" → verse (text)
*/
export async function getBsContent( export async function getBsContent(
slug: string, slug: string,
): Promise<BsVerse | BsIntroSegment | null> { ): Promise<BsVerse | BsIntroSegment | BsChapter | null> {
if (slug.startsWith("intro-")) { // Try as verse first (numeric slug)
return getBsIntroSegment(slug); if (/^\d+$/.test(slug)) {
const verse = await getBsVerse(slug);
if (verse) return verse;
} }
// Fall back to prose content (intro segments, etc.)
const num = Number(slug); return getContent(CODE, slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 62) {
return getBsVerse(String(num));
} }
export async function getBsSidebarItems() {
return null; return getSidebarItems(CODE);
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: Introduction → Foreword → Texts 1-62
*/
export async function getBsSidebarItems(): Promise<BsSidebarItem[]> {
const items: BsSidebarItem[] = [];
// 1. Introduction intro segment
items.push({
slug: "intro-introduction",
label: "Introduction",
type: "intro-segment",
text: null,
});
// 2. Foreword intro segment
items.push({
slug: "intro-foreword",
label: "Foreword",
type: "intro-segment",
text: null,
});
// 3. Texts 1-62 (verses)
for (let i = 1; i <= 62; i++) {
items.push({
slug: String(i),
label: `${i}. ${TEXT_TITLES[i]}`,
type: "verse",
text: i,
});
}
return items;
} }
+22 -292
View File
@@ -1,299 +1,29 @@
/** import {
* Data access layer for Śrī Caitanya-bhāgavata Commentary (CBHC). getBookMeta,
* getChapter,
* Reads from MongoDB via Prisma, same as the rest of the app. getContent,
* CBHC is a simple prose book with no volume structure and no intro segments — getSidebarItems,
* just 4 chapters directly. We use volume=0 as the sentinel for "no volumes". type BookMeta,
* type Chapter,
* When the DB isn't seeded yet, falls back to reading local JSON files type SidebarItem,
* from lib/jsons/books/cbhc/. } from "@/lib/books";
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db"; const CODE = "cbhc";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// --------------------------------------------------------------------------- export type CbhcMeta = BookMeta;
// Constants export type CbhcChapterEntry = { number: number; title: string };
// --------------------------------------------------------------------------- export type CbhcChapter = Chapter;
export type CbhcSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */ export async function getCbhcMeta() {
const NO_VOLUME = 0; return getBookMeta(CODE);
const BOOK = "cbhc";
const CBHC_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "cbhc");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type CbhcMeta = {
book: string;
title: string;
author: string;
year?: number | null;
description: string;
totalChapters: number;
chapters: CbhcChapterEntry[];
};
export type CbhcChapterEntry = {
number: number;
title: string;
};
export type CbhcChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type CbhcSidebarItem = {
/** URL-safe slug: "1", "2", "3", "4" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "chapter" */
type: string;
/** Chapter number */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getCbhcChapter(chapterNum: number) {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getChapter(CODE, String(chapterNum));
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getCbhcContent(slug: string) {
result = paragraphs as string[]; return getContent(CODE, slug);
} else {
result = [];
} }
export async function getCbhcSidebarItems() {
return result.map(sanitizeHtml); return getSidebarItems(CODE);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalChapter(chapterNum: number): CbhcChapter | null {
const filePath = path.join(
CBHC_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<CbhcChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getCbhcMeta(): Promise<CbhcMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as CbhcMeta;
// Fallback to local JSON
const introPath = path.join(CBHC_JSON_DIR, "intro.json");
return readLocalJson<CbhcMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–4).
*/
export async function getCbhcChapter(
chapterNum: number,
): Promise<CbhcChapter | null> {
if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "1" … "4" → chapter
*/
export async function getCbhcContent(
slug: string,
): Promise<CbhcChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
return getCbhcChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–4 (no intro segments)
*/
export async function getCbhcSidebarItems(): Promise<CbhcSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getCbhcMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(meta: CbhcMeta): CbhcSidebarItem[] {
const items: CbhcSidebarItem[] = [];
// Chapters 1–4 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: CbhcMeta | null): CbhcSidebarItem[] {
const items: CbhcSidebarItem[] = [];
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(CBHC_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<CbhcChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
} }
-11
View File
@@ -1,11 +0,0 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+113 -131
View File
@@ -2,14 +2,14 @@
* Data access layer for Disciples. * Data access layer for Disciples.
* *
* Provides queries for browsing disciples by letter, place, year, and search. * Provides queries for browsing disciples by letter, place, year, and search.
* Uses the Prisma Disciple model (collection: disciples). * Uses the BFF API instead of direct Prisma queries.
* *
* Disciples have an initiatedName and suffix (e.g., "Dasa", "Devi Dasi", "Sw"). * Disciples have an initiatedName and suffix (e.g., "Dasa", "Devi Dasi", "Sw").
* The letter index aggregates disciples by the first letter of their initiatedName. * The letter index aggregates disciples by the first letter of their initiatedName.
* Place and year groupings allow browsing by initiation location and year. * Place and year groupings allow browsing by initiation location and year.
*/ */
import { prisma } from "@/lib/db"; import { bff, bffList } from "@/lib/bff";
// Types // Types
@@ -37,26 +37,51 @@ export type YearGroup = {
count: number; count: number;
}; };
// --- Summary select clause (reused across queries) ---
const discipleSummarySelect = {
id: true,
initiatedName: true,
suffix: true,
initiationDate: true,
initiationPlace: true,
isVerified: true,
} as const;
// --- Suffix filter type --- // --- Suffix filter type ---
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi"; export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
// --- BFF response types (snake_case from Go API) ---
type BffDisciple = {
id: string;
initiated_name: string;
suffix: string;
initiation_date: string | null;
initiation_place: string | null;
karmi_name: string | null;
is_verified: boolean;
};
type BffLetterCount = {
_id: string;
count: number;
};
type BffSuffixCount = {
_id: string;
count: number;
};
// --- Field mapping helper ---
function mapDisciple(d: BffDisciple): DiscipleSummary {
return {
id: d.id,
initiatedName: d.initiated_name,
suffix: d.suffix,
initiationDate: d.initiation_date ? new Date(d.initiation_date) : null,
initiationPlace: d.initiation_place ?? null,
isVerified: d.is_verified,
};
}
// --- Count --- // --- Count ---
/** Get total number of disciples in the collection. */ /** Get total number of disciples in the collection. */
export async function getTotalDiscipleCount(): Promise<number> { export async function getTotalDiscipleCount(): Promise<number> {
return prisma.disciple.count(); const result = await bff<{ total: number }>("/disciples/count");
return result?.total ?? 0;
} }
/** Get count of disciples matching optional letter + suffix filters. */ /** Get count of disciples matching optional letter + suffix filters. */
@@ -64,14 +89,19 @@ export async function getFilteredDiscipleCount(
letter: string | null, letter: string | null,
suffix: SuffixFilter = "all", suffix: SuffixFilter = "all",
): Promise<number> { ): Promise<number> {
const where: Record<string, unknown> = {}; const params: Record<string, string> = {};
if (letter) { if (letter) params.letter = letter;
where.initiatedName = { startsWith: letter, mode: "insensitive" }; if (suffix !== "all") params.suffix = suffix;
// If no filters, just use total count endpoint
if (!letter && suffix === "all") {
return getTotalDiscipleCount();
} }
if (suffix !== "all") {
where.suffix = suffix; // Fetch filtered list and count client-side
} // TODO: Consider adding a dedicated BFF count endpoint with filter params for better performance
return prisma.disciple.count({ where }); const disciples = await bffList<BffDisciple>("/disciples", { params });
return disciples.length;
} }
// --- Letter-based queries --- // --- Letter-based queries ---
@@ -79,23 +109,16 @@ export async function getFilteredDiscipleCount(
/** /**
* Get all disciples whose initiatedName starts with the given letter. * Get all disciples whose initiatedName starts with the given letter.
* *
* Uses Prisma's `startsWith` filter with case-insensitive mode. * Uses BFF endpoint with letter query param.
* Results are ordered by initiatedName ascending. * Results are ordered by initiatedName ascending (handled by BFF).
*/ */
export async function getDisciplesByLetter( export async function getDisciplesByLetter(
letter: string, letter: string,
): Promise<DiscipleSummary[]> { ): Promise<DiscipleSummary[]> {
const disciples = await prisma.disciple.findMany({ const disciples = await bffList<BffDisciple>("/disciples", {
where: { params: { letter },
initiatedName: {
startsWith: letter,
mode: "insensitive",
},
},
orderBy: { initiatedName: "asc" },
select: discipleSummarySelect,
}); });
return disciples; return disciples.map(mapDisciple);
} }
/** /**
@@ -107,16 +130,11 @@ export async function getDisciplesByLetter(
export async function getAllDisciples( export async function getAllDisciples(
suffix: SuffixFilter = "all", suffix: SuffixFilter = "all",
): Promise<DiscipleSummary[]> { ): Promise<DiscipleSummary[]> {
const where: Record<string, unknown> = {}; const params: Record<string, string> = {};
if (suffix !== "all") { if (suffix !== "all") params.suffix = suffix;
where.suffix = suffix;
} const disciples = await bffList<BffDisciple>("/disciples", { params });
const disciples = await prisma.disciple.findMany({ return disciples.map(mapDisciple);
where,
orderBy: { initiatedName: "asc" },
select: discipleSummarySelect,
});
return disciples;
} }
/** /**
@@ -133,19 +151,12 @@ export async function getFilteredDisciples(
letter: string | null, letter: string | null,
suffix: SuffixFilter = "all", suffix: SuffixFilter = "all",
): Promise<DiscipleSummary[]> { ): Promise<DiscipleSummary[]> {
const where: Record<string, unknown> = {}; const params: Record<string, string> = {};
if (letter) { if (letter) params.letter = letter;
where.initiatedName = { startsWith: letter, mode: "insensitive" }; if (suffix !== "all") params.suffix = suffix;
}
if (suffix !== "all") { const disciples = await bffList<BffDisciple>("/disciples", { params });
where.suffix = suffix; return disciples.map(mapDisciple);
}
const disciples = await prisma.disciple.findMany({
where,
orderBy: { initiatedName: "asc" },
select: discipleSummarySelect,
});
return disciples;
} }
/** /**
@@ -156,42 +167,22 @@ export async function getFilteredDisciples(
export async function getDiscipleSuffixCounts(): Promise< export async function getDiscipleSuffixCounts(): Promise<
{ suffix: string; count: number }[] { suffix: string; count: number }[]
> { > {
const disciples = await prisma.disciple.findMany({ const suffixes = await bffList<BffSuffixCount>("/disciples/suffixes");
select: { suffix: true }, return suffixes
}); .map((s) => ({ suffix: s._id, count: s.count }))
const counts: Record<string, number> = {};
for (const d of disciples) {
counts[d.suffix] = (counts[d.suffix] || 0) + 1;
}
return Object.entries(counts)
.map(([suffix, count]) => ({ suffix, count }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
} }
/** /**
* Get counts of disciples grouped by the first letter of their initiatedName. * Get counts of disciples grouped by the first letter of their initiatedName.
* *
* Fetches all initiatedName values and aggregates first letters in JavaScript * Uses the BFF /disciples/letters endpoint which returns pre-aggregated data.
* (Prisma/MongoDB doesn't support substring aggregations easily).
* Returns results sorted alphabetically by letter. * Returns results sorted alphabetically by letter.
*/ */
export async function getDiscipleLetterCounts(): Promise<LetterCount[]> { export async function getDiscipleLetterCounts(): Promise<LetterCount[]> {
const disciples = await prisma.disciple.findMany({ const letters = await bffList<BffLetterCount>("/disciples/letters");
select: { initiatedName: true }, return letters
}); .map((l) => ({ letter: l._id, count: l.count }))
const counts: Record<string, number> = {};
for (const d of disciples) {
if (d.initiatedName && d.initiatedName.length > 0) {
const letter = d.initiatedName[0].toUpperCase();
counts[letter] = (counts[letter] || 0) + 1;
}
}
return Object.entries(counts)
.map(([letter, count]) => ({ letter, count }))
.sort((a, b) => a.letter.localeCompare(b.letter)); .sort((a, b) => a.letter.localeCompare(b.letter));
} }
@@ -200,19 +191,18 @@ export async function getDiscipleLetterCounts(): Promise<LetterCount[]> {
/** /**
* Get unique initiation places with disciple counts. * Get unique initiation places with disciple counts.
* *
* Fetches all non-null initiationPlace values and aggregates in JavaScript. * No dedicated BFF endpoint — fetches all disciples and aggregates in JS.
* Returns results sorted by count descending. * Returns results sorted by count descending.
*
* TODO: Add a BFF endpoint (GET /api/v1/disciples/places) for better performance.
*/ */
export async function getDisciplePlaces(): Promise<PlaceGroup[]> { export async function getDisciplePlaces(): Promise<PlaceGroup[]> {
const disciples = await prisma.disciple.findMany({ const disciples = await bffList<BffDisciple>("/disciples");
where: { initiationPlace: { not: null } },
select: { initiationPlace: true },
});
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const d of disciples) { for (const d of disciples) {
if (d.initiationPlace) { if (d.initiation_place) {
counts[d.initiationPlace] = (counts[d.initiationPlace] || 0) + 1; counts[d.initiation_place] = (counts[d.initiation_place] || 0) + 1;
} }
} }
@@ -224,17 +214,20 @@ export async function getDisciplePlaces(): Promise<PlaceGroup[]> {
/** /**
* Get all disciples initiated at a given place. * Get all disciples initiated at a given place.
* *
* No dedicated BFF endpoint — fetches all disciples and filters in JS.
* Results are ordered by initiatedName ascending. * Results are ordered by initiatedName ascending.
*
* TODO: Add BFF query param support (GET /api/v1/disciples?place=...) for better performance.
*/ */
export async function getDisciplesByPlace( export async function getDisciplesByPlace(
place: string, place: string,
): Promise<DiscipleSummary[]> { ): Promise<DiscipleSummary[]> {
const disciples = await prisma.disciple.findMany({ const disciples = await bffList<BffDisciple>("/disciples");
where: { initiationPlace: place },
orderBy: { initiatedName: "asc" }, return disciples
select: discipleSummarySelect, .filter((d) => d.initiation_place === place)
}); .map(mapDisciple)
return disciples; .sort((a, b) => a.initiatedName.localeCompare(b.initiatedName));
} }
// --- Year-based queries --- // --- Year-based queries ---
@@ -242,23 +235,23 @@ export async function getDisciplesByPlace(
/** /**
* Get unique initiation years with disciple counts. * Get unique initiation years with disciple counts.
* *
* Fetches all non-null initiationDate values and aggregates years in JavaScript * No dedicated BFF endpoint — fetches all disciples and aggregates years in JS.
* (Prisma/MongoDB doesn't support date-part aggregations easily).
* Returns results sorted by year ascending. * Returns results sorted by year ascending.
*
* TODO: Add a BFF endpoint (GET /api/v1/disciples/years) for better performance.
*/ */
export async function getDiscipleYears(): Promise<YearGroup[]> { export async function getDiscipleYears(): Promise<YearGroup[]> {
const disciples = await prisma.disciple.findMany({ const disciples = await bffList<BffDisciple>("/disciples");
where: { initiationDate: { not: null } },
select: { initiationDate: true },
});
const counts: Record<number, number> = {}; const counts: Record<number, number> = {};
for (const d of disciples) { for (const d of disciples) {
if (d.initiationDate) { if (d.initiation_date) {
const year = d.initiationDate.getFullYear(); const year = new Date(d.initiation_date).getFullYear();
if (!isNaN(year)) {
counts[year] = (counts[year] || 0) + 1; counts[year] = (counts[year] || 0) + 1;
} }
} }
}
return Object.entries(counts) return Object.entries(counts)
.map(([year, count]) => ({ year: Number(year), count })) .map(([year, count]) => ({ year: Number(year), count }))
@@ -268,27 +261,24 @@ export async function getDiscipleYears(): Promise<YearGroup[]> {
/** /**
* Get all disciples initiated in a given year. * Get all disciples initiated in a given year.
* *
* Uses a date range query (gte: Jan 1 of year, lt: Jan 1 of year+1) * No dedicated BFF endpoint — fetches all disciples and filters in JS.
* to match all initiationDate values within the calendar year.
* Results are ordered by initiatedName ascending. * Results are ordered by initiatedName ascending.
*
* TODO: Add BFF query param support (GET /api/v1/disciples?year=...) for better performance.
*/ */
export async function getDisciplesByYear( export async function getDisciplesByYear(
year: number, year: number,
): Promise<DiscipleSummary[]> { ): Promise<DiscipleSummary[]> {
const startOfYear = new Date(year, 0, 1); const disciples = await bffList<BffDisciple>("/disciples");
const startOfNextYear = new Date(year + 1, 0, 1);
const disciples = await prisma.disciple.findMany({ return disciples
where: { .filter((d) => {
initiationDate: { if (!d.initiation_date) return false;
gte: startOfYear, const dYear = new Date(d.initiation_date).getFullYear();
lt: startOfNextYear, return dYear === year;
}, })
}, .map(mapDisciple)
orderBy: { initiatedName: "asc" }, .sort((a, b) => a.initiatedName.localeCompare(b.initiatedName));
select: discipleSummarySelect,
});
return disciples;
} }
// --- Search --- // --- Search ---
@@ -296,22 +286,14 @@ export async function getDisciplesByYear(
/** /**
* Search disciples by initiatedName using case-insensitive matching. * Search disciples by initiatedName using case-insensitive matching.
* *
* Uses Prisma's `contains` filter with case-insensitive mode. * Uses BFF search endpoint.
* Results are limited to 100 and ordered by initiatedName ascending. * Results are limited to 100 and ordered by initiatedName ascending.
*/ */
export async function searchDisciples( export async function searchDisciples(
query: string, query: string,
): Promise<DiscipleSummary[]> { ): Promise<DiscipleSummary[]> {
const disciples = await prisma.disciple.findMany({ const disciples = await bffList<BffDisciple>("/disciples/search", {
where: { params: { q: query },
initiatedName: {
contains: query,
mode: "insensitive",
},
},
orderBy: { initiatedName: "asc" },
take: 100,
select: discipleSummarySelect,
}); });
return disciples; return disciples.map(mapDisciple);
} }
+33 -311
View File
@@ -1,325 +1,47 @@
/** /**
* Data access layer for Dialectic Spiritualism (DS). * Data access layer for Dialectic Spiritualism (DS).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* DS is a large prose/dialogue book with 3 intro segments (front-pages,
* foreword, preface) and 13 parent chapters, each containing philosopher
* sub-chapters (e.g., "1-1" Socrates, "1-2" Plato, "1-3" Aristotle).
* 38 philosopher sub-chapters total.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
getNavigableItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "ds";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "ds"; export type DsMeta = BookMeta;
export type DsSubChapterEntry = { slug: string; title: string };
export type DsIntroSegment = IntroSegment;
export type DsChapter = Chapter;
export type DsSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */ export async function getDsMeta() {
const NO_VOLUME = 0; return getBookMeta(CODE);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DsMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: DsParentChapter[];
};
export type DsParentChapter = {
number: number;
title: string;
subChapters: DsSubChapterEntry[];
};
export type DsSubChapterEntry = {
slug: string; // "1-1", "1-2", etc.
title: string;
};
export type DsIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type DsChapter = {
book: string;
chapter: string; // "1-1", "2-1", etc.
parentChapter: number;
subChapter: number;
title: string;
parentTitle: string;
type: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type DsSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1-1", "parent-1", etc. */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "parent" | "sub-chapter" */
type: string;
/** Parent chapter number, or null for intro segments */
parentChapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
} }
export async function getDsIntroSegments() {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getIntroSegments(CODE);
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getDsIntroSegment(slug: string) {
result = paragraphs as string[]; return getIntroSegment(CODE, slug);
} else {
result = [];
} }
export async function getDsChapter(slug: string) {
return result.map(sanitizeHtml); return getChapter(CODE, slug);
} }
export async function getDsContent(slug: string) {
// --------------------------------------------------------------------------- return getContent(CODE, slug);
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
*/
export async function getDsMeta(): Promise<DsMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as DsMeta;
} }
export async function getDsSidebarItems() {
// --------------------------------------------------------------------------- return getSidebarItems(CODE);
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getDsIntroSegments(): Promise<DsIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getDsIntroSegment(
slug: string,
): Promise<DsIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters (sub-chapters like "1-1", "2-3", etc.)
// ---------------------------------------------------------------------------
/**
* A sub-chapter by its slug (e.g. "1-1", "2-3").
*/
export async function getDsChapter(slug: string): Promise<DsChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug,
},
},
});
if (!doc) return null;
// Parse parentChapter and subChapter from slug "X-Y"
const parts = slug.split("-");
const parentChapter = parseInt(parts[0], 10);
const subChapter = parseInt(parts[1], 10);
// Derive parentTitle from meta if available
const meta = await getDsMeta();
const parentEntry = meta?.chapters.find((ch) => ch.number === parentChapter);
const parentTitle = parentEntry?.title ?? "";
return {
book: doc.book,
chapter: slug,
parentChapter,
subChapter,
title: doc.title,
parentTitle,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-foreword" → intro segment
* "intro-preface" → intro segment
* "1-1", "2-3", etc. → sub-chapter
*/
export async function getDsContent(
slug: string,
): Promise<DsChapter | DsIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getDsIntroSegment(slug);
}
// Sub-chapter slug like "1-1", "2-3"
return getDsChapter(slug);
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list with hierarchical structure.
*
* Order: front-pages → foreword → preface
* → parent-1 (label) → 1-1, 1-2, 1-3 → parent-2 (label) → 2-1, 2-2, ...
*
* "parent" type items are non-clickable headings. Only "intro-segment" and
* "sub-chapter" items are navigable pages.
*/
export async function getDsSidebarItems(): Promise<DsSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getDsMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
if (!meta) return [];
const items: DsSidebarItem[] = [];
// Intro segments (all come before chapters — no back matter for DS)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
parentChapter: null,
});
}
}
// Chapters — grouped by parent chapter with sub-chapters
for (const ch of meta.chapters) {
// Parent heading (non-clickable label)
items.push({
slug: `parent-${ch.number}`,
label: `${ch.number}. ${ch.title}`,
type: "parent",
parentChapter: ch.number,
});
// Sub-chapters under this parent
for (const sub of ch.subChapters) {
items.push({
slug: sub.slug,
label: sub.title,
type: "sub-chapter",
parentChapter: ch.number,
});
}
}
return items;
}
/**
* Get the flat list of navigable sidebar items (excludes "parent" headings).
* Used for prev/next navigation.
*/
export function getNavigableItems(items: DsSidebarItem[]): DsSidebarItem[] {
return items.filter((item) => item.type !== "parent");
} }
export { getNavigableItems };
+26 -251
View File
@@ -1,282 +1,57 @@
/** /**
* Data access layer for Easy Journey to Other Planets (EJ). * Data access layer for Easy Journey to Other Planets (EJ).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* EJ is a simple prose book with 2 chapters and 2 intro segments.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "ej";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "ej"; // Re-export types with book-specific names for backwards compatibility
export type EjMeta = BookMeta;
export type EjChapterEntry = { number: number; title: string };
export type EjIntroSegment = IntroSegment;
export type EjChapter = Chapter;
export type EjSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type EjMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: EjChapterEntry[];
};
export type EjChapterEntry = {
number: number;
title: string;
};
export type EjIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type EjChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type EjSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", "2" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/nod.ts)
// ---------------------------------------------------------------------------
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;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getEjMeta(): Promise<EjMeta | null> { export async function getEjMeta(): Promise<EjMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); return getBookMeta(CODE);
if (!meta) return null;
return meta.data as unknown as EjMeta;
} }
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getEjIntroSegments(): Promise<EjIntroSegment[]> { export async function getEjIntroSegments(): Promise<EjIntroSegment[]> {
const results = await prisma.proseChapter.findMany({ return getIntroSegments(CODE);
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getEjIntroSegment( export async function getEjIntroSegment(
slug: string, slug: string,
): Promise<EjIntroSegment | null> { ): Promise<EjIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form return getIntroSegment(CODE, slug);
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–2).
*/
export async function getEjChapter( export async function getEjChapter(
chapterNum: number, chapterNum: number,
): Promise<EjChapter | null> { ): Promise<EjChapter | null> {
if (chapterNum < 1 || chapterNum > 2 || !Number.isInteger(chapterNum)) { return getChapter(CODE, String(chapterNum));
return null;
} }
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-preface" → intro segment
* "1", "2" → chapter
*/
export async function getEjContent( export async function getEjContent(
slug: string, slug: string,
): Promise<EjChapter | EjIntroSegment | null> { ): Promise<EjChapter | EjIntroSegment | null> {
if (slug.startsWith("intro-")) { return getContent(CODE, slug);
return getEjIntroSegment(slug);
} }
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
return getEjChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → preface → chapters 1–2
*/
export async function getEjSidebarItems(): Promise<EjSidebarItem[]> { export async function getEjSidebarItems(): Promise<EjSidebarItem[]> {
const [meta, allDocs] = await Promise.all([ return getSidebarItems(CODE);
getEjMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: EjSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–2 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
} }
+26 -249
View File
@@ -1,280 +1,57 @@
/** /**
* Data access layer for Elevation to Kṛṣṇa Consciousness (EK). * Data access layer for Elevation to Kṛṣṇa Consciousness (EK).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* EK is a simple prose book with 1 intro segment (front-pages) and 6 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "ek";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "ek"; // Re-export types with book-specific names for backwards compatibility
export type EkMeta = BookMeta;
export type EkChapterEntry = { number: number; title: string };
export type EkIntroSegment = IntroSegment;
export type EkChapter = Chapter;
export type EkSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type EkMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: EkChapterEntry[];
};
export type EkChapterEntry = {
number: number;
title: string;
};
export type EkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type EkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type EkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "6" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getEkMeta(): Promise<EkMeta | null> { export async function getEkMeta(): Promise<EkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); return getBookMeta(CODE);
if (!meta) return null;
return meta.data as unknown as EkMeta;
} }
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getEkIntroSegments(): Promise<EkIntroSegment[]> { export async function getEkIntroSegments(): Promise<EkIntroSegment[]> {
const results = await prisma.proseChapter.findMany({ return getIntroSegments(CODE);
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getEkIntroSegment( export async function getEkIntroSegment(
slug: string, slug: string,
): Promise<EkIntroSegment | null> { ): Promise<EkIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form return getIntroSegment(CODE, slug);
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–6).
*/
export async function getEkChapter( export async function getEkChapter(
chapterNum: number, chapterNum: number,
): Promise<EkChapter | null> { ): Promise<EkChapter | null> {
if (chapterNum < 1 || chapterNum > 6 || !Number.isInteger(chapterNum)) { return getChapter(CODE, String(chapterNum));
return null;
} }
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "6" → chapter
*/
export async function getEkContent( export async function getEkContent(
slug: string, slug: string,
): Promise<EkChapter | EkIntroSegment | null> { ): Promise<EkChapter | EkIntroSegment | null> {
if (slug.startsWith("intro-")) { return getContent(CODE, slug);
return getEkIntroSegment(slug);
} }
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
return getEkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–6
*/
export async function getEkSidebarItems(): Promise<EkSidebarItem[]> { export async function getEkSidebarItems(): Promise<EkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([ return getSidebarItems(CODE);
getEkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: EkSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–6 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
} }
+30 -434
View File
@@ -1,443 +1,39 @@
/** import {
* Data access layer for Gītār Gān (GG). getBookMeta,
* getIntroSegments,
* Reads from MongoDB via Prisma, same as the rest of the app. getIntroSegment,
* GG is a prose/poetry book with no volume structure — getChapter,
* we use volume=0 as the sentinel for "no volumes". getContent,
* getSidebarItems,
* When the DB isn't seeded yet, falls back to reading local JSON files type BookMeta,
* from lib/jsons/books/gg/. type IntroSegment,
* type Chapter,
* Collections used: type SidebarItem,
* - book_meta (via Prisma BookMeta model) } from "@/lib/books";
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db"; const CODE = "gg";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// --------------------------------------------------------------------------- export type GgMeta = BookMeta;
// Constants export type GgChapterEntry = { number: number; title: string };
// --------------------------------------------------------------------------- export type GgIntroSegment = IntroSegment;
export type GgChapter = Chapter;
export type GgSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */ export async function getGgMeta() {
const NO_VOLUME = 0; return getBookMeta(CODE);
const BOOK = "gg";
const GG_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "gg");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type GgMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: GgChapterEntry[];
};
export type GgChapterEntry = {
number: number;
title: string;
};
export type GgIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type GgChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type GgSidebarItem = {
/** URL-safe slug: "intro-introduction", "1", "18" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getGgIntroSegments() {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getIntroSegments(CODE);
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getGgIntroSegment(slug: string) {
result = paragraphs as string[]; return getIntroSegment(CODE, slug);
} else {
result = [];
} }
export async function getGgChapter(chapterNum: number) {
return result.map(sanitizeHtml); return getChapter(CODE, String(chapterNum));
} }
export async function getGgContent(slug: string) {
// --------------------------------------------------------------------------- return getContent(CODE, slug);
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
} }
} export async function getGgSidebarItems() {
return getSidebarItems(CODE);
function getLocalIntroSegments(): GgIntroSegment[] {
const introDir = path.join(GG_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: GgIntroSegment[] = [];
const files = ["introduction.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<GgIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): GgIntroSegment | null {
const filePath = path.join(GG_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<GgIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): GgChapter | null {
const filePath = path.join(
GG_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<GgChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getGgMeta(): Promise<GgMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as GgMeta;
// Fallback to local JSON
const introPath = path.join(GG_JSON_DIR, "intro.json");
return readLocalJson<GgMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getGgIntroSegments(): Promise<GgIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
*/
export async function getGgIntroSegment(
slug: string,
): Promise<GgIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–18).
*/
export async function getGgChapter(
chapterNum: number,
): Promise<GgChapter | null> {
if (chapterNum < 1 || chapterNum > 18 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-introduction" → intro segment
* "1" … "18" → chapter
*/
export async function getGgContent(
slug: string,
): Promise<GgChapter | GgIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getGgIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
return getGgChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: introduction → chapters 1–18
*/
export async function getGgSidebarItems(): Promise<GgSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getGgMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: GgMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): GgSidebarItem[] {
const items: GgSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–18 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
// Intro segments that come AFTER chapters (order >= 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: GgMeta | null): GgSidebarItem[] {
const items: GgSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
for (const seg of frontMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or generate default 1-18
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(GG_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<GgChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
// Back matter
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
} }
+32 -269
View File
@@ -1,282 +1,45 @@
/** /**
* Data access layer for The Kṛṣṇa Consciousness Handbook (HKH). * Data access layer for The Hare Kṛṣṇa Handbook (HKH).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* HKH is a prose book with 2 intro segments (front-pages, introduction)
* and 19 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "hkh";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "hkh"; export type HkhMeta = BookMeta;
export type HkhChapterEntry = { number: number; title: string };
export type HkhIntroSegment = IntroSegment;
export type HkhChapter = Chapter;
export type HkhSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */ export async function getHkhMeta() {
const NO_VOLUME = 0; return getBookMeta(CODE);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type HkhMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: HkhChapterEntry[];
};
export type HkhChapterEntry = {
number: number;
title: string;
};
export type HkhIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type HkhChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type HkhSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-introduction", "1", "2", … "7" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
} }
export async function getHkhIntroSegments() {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getIntroSegments(CODE);
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getHkhIntroSegment(slug: string) {
result = paragraphs as string[]; return getIntroSegment(CODE, slug);
} else {
result = [];
} }
export async function getHkhChapter(chapterNum: number) {
return result.map(sanitizeHtml); return getChapter(CODE, String(chapterNum));
} }
export async function getHkhContent(slug: string) {
// --------------------------------------------------------------------------- return getContent(CODE, slug);
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getHkhMeta(): Promise<HkhMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as HkhMeta;
} }
export async function getHkhSidebarItems() {
// --------------------------------------------------------------------------- return getSidebarItems(CODE);
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getHkhIntroSegments(): Promise<HkhIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getHkhIntroSegment(
slug: string,
): Promise<HkhIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–19).
*/
export async function getHkhChapter(
chapterNum: number,
): Promise<HkhChapter | null> {
if (chapterNum < 1 || chapterNum > 19 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-introduction" → intro segment
* "1" … "7" → chapter
*/
export async function getHkhContent(
slug: string,
): Promise<HkhChapter | HkhIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getHkhIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 19) {
return getHkhChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → chapters 1–19
*/
export async function getHkhSidebarItems(): Promise<HkhSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getHkhMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: HkhSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–19 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
} }
+41 -313
View File
@@ -1,225 +1,47 @@
/** /**
* Data access layer for Śrī Īśopaniṣad (ISO). * Data access layer for Śrī Īśopaniṣad (ISO).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer (BFF API) for prose intro segments,
* ISO is unique — it has BOTH verses (in the `verses` collection) * and to lib/api.ts for verse (mantra) lookups.
* AND prose intro segments (in the `prose_chapters` collection).
*
* Verses were seeded with:
* - book: "iso", division: null
* - chapter = mantra number (0 for Invocation, 1-18 for mantras)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "iso", volume: 0, type: "intro-segment"
* - slugs like "intro-front-pages", "intro-introduction", "intro-about-the-author"
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { getSingleVerse, type VerseRecord } from "@/lib/api";
// --------------------------------------------------------------------------- const CODE = "iso";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "iso"; export type IsoMeta = BookMeta;
const NO_VOLUME = 0; export type IsoIntroSegment = IntroSegment;
export type IsoChapter = Chapter;
export type IsoSidebarItem = SidebarItem;
export type IsoVerse = VerseRecord;
// --------------------------------------------------------------------------- export async function getIsoMeta() {
// Mantra title lookup return getBookMeta(CODE);
// ---------------------------------------------------------------------------
const MANTRA_TITLES: Record<number, string> = {
0: "Invocation",
1: "Mantra One",
2: "Mantra Two",
3: "Mantra Three",
4: "Mantra Four",
5: "Mantra Five",
6: "Mantra Six",
7: "Mantra Seven",
8: "Mantra Eight",
9: "Mantra Nine",
10: "Mantra Ten",
11: "Mantra Eleven",
12: "Mantra Twelve",
13: "Mantra Thirteen",
14: "Mantra Fourteen",
15: "Mantra Fifteen",
16: "Mantra Sixteen",
17: "Mantra Seventeen",
18: "Mantra Eighteen",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type IsoMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalMantras: number;
mantras: IsoMantraEntry[];
};
export type IsoMantraEntry = {
number: number;
title: string;
slug: string;
};
export type IsoIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type IsoVerse = {
book: string;
book_title: string;
mantra: number;
title: string;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type IsoSidebarItem = {
/** URL-safe slug: "intro-front-pages", "invocation", "1", "intro-about-the-author" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Mantra number (0 for invocation), or null for non-verse items */
mantra: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/nod.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getIsoIntroSegments() {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getIntroSegments(CODE);
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getIsoIntroSegment(slug: string) {
result = paragraphs as string[]; return getIntroSegment(CODE, slug);
} else {
result = [];
} }
return result.map(sanitizeHtml);
}
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
function normalizePurport(purport: string[] | JsonValue): string[] {
return normalizeParagraphs(purport);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, mantras list, etc.)
*/
export async function getIsoMeta(): Promise<IsoMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as IsoMeta;
}
// ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection)
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getIsoIntroSegments(): Promise<IsoIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getIsoIntroSegment(
slug: string,
): Promise<IsoIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Verses (from verses collection)
// ---------------------------------------------------------------------------
/** /**
* A single verse/mantra by slug. * A single verse/mantra by slug.
* *
* - "invocation" → chapter=0, verse=1 * ISO verses are stored with: book "iso", no division,
* - "1"-"18" → chapter=1–18, verse=1 * chapter = mantra number (0 for invocation), verse = 1.
*/ */
export async function getIsoVerse(slug: string): Promise<IsoVerse | null> { export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
let mantraNum: number; let mantraNum: number;
@@ -227,119 +49,25 @@ export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
if (slug === "invocation") { if (slug === "invocation") {
mantraNum = 0; mantraNum = 0;
} else { } else {
const num = Number(slug); const num = parseInt(slug, 10);
if (!Number.isInteger(num) || num < 1 || num > 18) return null; if (isNaN(num) || num < 1 || num > 18) return null;
mantraNum = num; mantraNum = num;
} }
const doc = await prisma.verse.findFirst({ return getSingleVerse(CODE, null, mantraNum, 1);
where: { book: BOOK, division: null, chapter: mantraNum, verse: 1 },
});
if (!doc) return null;
return {
book: doc.book,
book_title: doc.bookTitle,
mantra: doc.chapter, // chapter field stores the mantra number
title: MANTRA_TITLES[doc.chapter] ?? `Mantra ${doc.chapter}`,
reference: doc.reference,
devanagari: doc.devanagari,
transliteration: doc.transliteration,
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
translation: sanitizeHtml(doc.translation),
purport: normalizePurport(doc.purport),
};
} }
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "invocation" → invocation verse
* "1" … "18" → mantra verse
*/
export async function getIsoContent( export async function getIsoContent(
slug: string, slug: string,
): Promise<IsoVerse | IsoIntroSegment | null> { ): Promise<IsoVerse | IsoIntroSegment | IsoChapter | null> {
if (slug.startsWith("intro-")) { // Try as verse/mantra first (numeric slug or "invocation")
return getIsoIntroSegment(slug); if (slug === "invocation" || /^\d+$/.test(slug)) {
const verse = await getIsoVerse(slug);
if (verse) return verse;
} }
// Fall back to prose content (intro segments, etc.)
if (slug === "invocation") { return getContent(CODE, slug);
return getIsoVerse("invocation");
} }
export async function getIsoSidebarItems() {
const num = Number(slug); return getSidebarItems(CODE);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
return getIsoVerse(String(num));
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → introduction → invocation → mantras 1-18 → about-the-author
*/
export async function getIsoSidebarItems(): Promise<IsoSidebarItem[]> {
const meta = await getIsoMeta();
if (!meta) return [];
const items: IsoSidebarItem[] = [];
// 1. Front pages intro segment
items.push({
slug: "intro-front-pages",
label: "Back Cover & Front Pages",
type: "intro-segment",
mantra: null,
});
// 2. Introduction (Teachings of the Vedas)
items.push({
slug: "intro-introduction",
label: "Introduction (Teachings of the Vedas)",
type: "intro-segment",
mantra: null,
});
// 3. Invocation + Mantras 1-18 (from meta.mantras)
for (const m of meta.mantras) {
if (m.number === 0) {
items.push({
slug: "invocation",
label: "Invocation",
type: "verse",
mantra: 0,
});
} else {
items.push({
slug: String(m.number),
label: `${m.number}. ${m.title}`,
type: "verse",
mantra: m.number,
});
}
}
// 4. About the Author intro segment (at the end)
items.push({
slug: "intro-about-the-author",
label: "About the Author",
type: "intro-segment",
mantra: null,
});
return items;
} }
+82 -35
View File
@@ -1,13 +1,11 @@
/** /**
* Data access layer for Journals. * Data access layer for Journals.
* *
* Uses dedicated Prisma models: * Uses the BFF API instead of direct Prisma queries.
* - JournalMeta (collection: journal_meta) * Falls back to local JSON files when BFF is unavailable.
* - JournalChapter (collection: journal_chapters)
*/ */
import { prisma } from "@/lib/db"; import { bff, bffList } from "@/lib/bff";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@@ -65,6 +63,31 @@ export type JournalSidebarItem = {
chapter: number; chapter: number;
}; };
// --- BFF response types ---
type BffJournalMeta = {
id: string;
book: string;
data: {
title: string;
author: string;
year: number | null;
description: string | null;
totalChapters: number;
chapters: JournalChapterEntry[];
};
};
type BffJournalChapter = {
book: string;
chapter: number;
title: string;
type: string;
slug: string;
paragraphs: string[];
paragraphs_json?: string;
};
// HTML sanitisation // HTML sanitisation
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, ""); let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
@@ -80,7 +103,7 @@ function sanitizeHtml(html: string): string {
return clean; return clean;
} }
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] { function normalizeParagraphs(paragraphs: string[] | unknown): string[] {
let result: string[]; let result: string[];
if (typeof paragraphs === "string") { if (typeof paragraphs === "string") {
try { try {
@@ -97,6 +120,51 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
return result.map(sanitizeHtml); return result.map(sanitizeHtml);
} }
// --- BFF → domain mappers ---
function mapBffMetaToJournalMetaData(raw: BffJournalMeta): JournalMetaData {
return {
book: raw.book,
title: raw.data.title,
author: raw.data.author,
year: raw.data.year ?? null,
description: raw.data.description ?? null,
totalChapters: raw.data.totalChapters,
chapters: raw.data.chapters,
};
}
function mapBffMetaToSummary(raw: BffJournalMeta): JournalSummary {
return {
book: raw.book,
title: raw.data.title,
author: raw.data.author,
year: raw.data.year ?? null,
description: raw.data.description ?? null,
totalChapters: raw.data.totalChapters,
};
}
function mapBffChapter(raw: BffJournalChapter): JournalChapterData {
// Handle paragraphs_json fallback
let paragraphs = raw.paragraphs;
if ((!paragraphs || !paragraphs.length) && raw.paragraphs_json) {
try {
paragraphs = JSON.parse(raw.paragraphs_json);
} catch {
paragraphs = [];
}
}
return {
book: raw.book,
chapter: raw.chapter,
title: raw.title,
type: raw.type,
paragraphs: normalizeParagraphs(paragraphs),
};
}
// Local JSON fallback helpers // Local JSON fallback helpers
function findLocalDirForBook(bookCode: string): string | null { function findLocalDirForBook(bookCode: string): string | null {
if (!fs.existsSync(LOCAL_JSON_DIR)) return null; if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
@@ -160,21 +228,9 @@ function getLocalChapterBySlug(
// Public API // Public API
export async function getAllJournals(): Promise<JournalSummary[]> { export async function getAllJournals(): Promise<JournalSummary[]> {
try { try {
const results = await prisma.journalMeta.findMany({ const results = await bffList<BffJournalMeta>("/journals");
orderBy: { book: "asc" },
});
if (results.length > 0) { if (results.length > 0) {
return results.map((row) => { return results.map(mapBffMetaToSummary);
const data = row.data as unknown as JournalMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters,
};
});
} }
} catch { } catch {
/* fall through */ /* fall through */
@@ -194,10 +250,8 @@ export async function getJournalMeta(
bookCode: string, bookCode: string,
): Promise<JournalMetaData | null> { ): Promise<JournalMetaData | null> {
try { try {
const row = await prisma.journalMeta.findUnique({ const raw = await bff<BffJournalMeta>(`/journals/${bookCode}`);
where: { book: bookCode }, if (raw) return mapBffMetaToJournalMetaData(raw);
});
if (row) return row.data as unknown as JournalMetaData;
} catch { } catch {
/* fall through */ /* fall through */
} }
@@ -210,17 +264,10 @@ export async function getJournalChapterBySlug(
): Promise<JournalChapterData | null> { ): Promise<JournalChapterData | null> {
if (!slug) return null; if (!slug) return null;
try { try {
const doc = await prisma.journalChapter.findUnique({ const raw = await bff<BffJournalChapter>(
where: { book_slug: { book: bookCode, slug } }, `/journals/${bookCode}/chapters/${slug}`,
}); );
if (doc) if (raw) return mapBffChapter(raw);
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} catch { } catch {
/* fall through */ /* fall through */
} }
+31 -431
View File
@@ -1,445 +1,45 @@
/** /**
* Data access layer for Life Comes from Life (LCL). * Data access layer for Life Comes from Life (LCL).
* *
* Reads from MongoDB via Prisma, same as the rest of the app. * Delegates to the generic book data layer which calls the BFF API.
* LCL is a prose/dialogue book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/lcl/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
import fs from "fs"; getIntroSegments,
import path from "path"; getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "lcl";
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */ export type LclMeta = BookMeta;
const NO_VOLUME = 0; export type LclChapterEntry = { number: number; title: string };
export type LclIntroSegment = IntroSegment;
export type LclChapter = Chapter;
export type LclSidebarItem = SidebarItem;
const BOOK = "lcl"; export async function getLclMeta() {
return getBookMeta(CODE);
const LCL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lcl");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LclMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: LclChapterEntry[];
};
export type LclChapterEntry = {
number: number;
title: string;
};
export type LclIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type LclChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type LclSidebarItem = {
/** URL-safe slug: "intro-foreword", "1", "16", "intro-about-the-author" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getLclIntroSegments() {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getIntroSegments(CODE);
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getLclIntroSegment(slug: string) {
result = paragraphs as string[]; return getIntroSegment(CODE, slug);
} else {
result = [];
} }
export async function getLclChapter(chapterNum: number) {
return result.map(sanitizeHtml); return getChapter(CODE, String(chapterNum));
} }
export async function getLclContent(slug: string) {
// --------------------------------------------------------------------------- return getContent(CODE, slug);
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
} }
} export async function getLclSidebarItems() {
return getSidebarItems(CODE);
function getLocalIntroSegments(): LclIntroSegment[] {
const introDir = path.join(LCL_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: LclIntroSegment[] = [];
const files = ["foreword.json", "introduction.json", "about-the-author.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<LclIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): LclIntroSegment | null {
const filePath = path.join(LCL_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<LclIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): LclChapter | null {
const filePath = path.join(
LCL_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<LclChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getLclMeta(): Promise<LclMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as LclMeta;
// Fallback to local JSON
const introPath = path.join(LCL_JSON_DIR, "intro.json");
return readLocalJson<LclMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getLclIntroSegments(): Promise<LclIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
*/
export async function getLclIntroSegment(
slug: string,
): Promise<LclIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–16).
*/
export async function getLclChapter(
chapterNum: number,
): Promise<LclChapter | null> {
if (chapterNum < 1 || chapterNum > 16 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-foreword" → intro segment
* "intro-introduction" → intro segment
* "intro-about-the-author" → intro segment
* "1" … "16" → chapter
*/
export async function getLclContent(
slug: string,
): Promise<LclChapter | LclIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getLclIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 16) {
return getLclChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: foreword → introduction → chapters 1–16 → about-the-author
*/
export async function getLclSidebarItems(): Promise<LclSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getLclMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: LclMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): LclSidebarItem[] {
const items: LclSidebarItem[] = [];
// Intro segments that come BEFORE chapters (order < 99)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–16 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
// Intro segments that come AFTER chapters (order >= 99, e.g. about-the-author)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
return items;
}
function buildSidebarFromLocal(meta: LclMeta | null): LclSidebarItem[] {
const items: LclSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
for (const seg of frontMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or generate default 1-16
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(LCL_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<LclChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Chapter ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
// Back matter (about-the-author)
for (const seg of backMatter) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
return items;
} }
+135 -121
View File
@@ -2,7 +2,7 @@
* Data access layer for Letters. * Data access layer for Letters.
* *
* Provides queries for browsing letters by year, recipient, and location. * Provides queries for browsing letters by year, recipient, and location.
* Uses the Prisma Letter model (collection: letters). * Calls the BFF API for all data access (no direct Prisma usage).
* *
* Recipients are stored as an array (e.g., ["Mukunda", "Janaki"]). * Recipients are stored as an array (e.g., ["Mukunda", "Janaki"]).
* The recipient list aggregates individual names across all letters, * The recipient list aggregates individual names across all letters,
@@ -10,9 +10,12 @@
* ANY recipient (individual or group). * ANY recipient (individual or group).
*/ */
import { prisma } from "@/lib/db"; import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types // Types
// ---------------------------------------------------------------------------
export type LetterSummary = { export type LetterSummary = {
slug: string; slug: string;
recipients: string[]; recipients: string[];
@@ -52,7 +55,43 @@ export type LocationGroup = {
count: number; count: number;
}; };
// Sanitize // ---------------------------------------------------------------------------
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
type BffLetterSummary = {
slug: string;
recipients: string[];
recipient_display: string;
date: string | null;
year: number | null;
month: number | null;
location: string | null;
};
type BffLetterFull = {
slug: string;
recipients: string[];
recipient_display: string;
date: string | null;
date_ref: string | null;
year: number | null;
month: number | null;
day: number | null;
location: string | null;
salutation: string | null;
paragraphs: string[];
};
type BffAggregation = {
_id: string | number;
count: number;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, ""); let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, ""); clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
@@ -65,23 +104,60 @@ function sanitizeHtml(html: string): string {
return clean; return clean;
} }
// --- Index/overview queries --- // ---------------------------------------------------------------------------
// Mappers (BFF snake_case → frontend camelCase)
// ---------------------------------------------------------------------------
function mapLetterSummary(item: BffLetterSummary): LetterSummary {
return {
slug: item.slug,
recipients: item.recipients ?? [],
recipientDisplay: item.recipient_display ?? "",
date: item.date ?? null,
year: item.year ?? null,
month: item.month ?? null,
location: item.location ?? null,
};
}
function mapLetterFull(item: BffLetterFull): LetterFull {
return {
slug: item.slug,
recipients: item.recipients ?? [],
recipientDisplay: item.recipient_display ?? "",
date: item.date ?? null,
dateRef: item.date_ref ?? null,
year: item.year ?? null,
month: item.month ?? null,
day: item.day ?? null,
location: item.location ?? null,
salutation: item.salutation ?? null,
paragraphs: (item.paragraphs ?? []).map(sanitizeHtml),
};
}
function mapYearGroup(item: BffAggregation): YearGroup {
return { year: Number(item._id), count: item.count };
}
function mapRecipientGroup(item: BffAggregation): RecipientGroup {
return { name: String(item._id), count: item.count };
}
function mapLocationGroup(item: BffAggregation): LocationGroup {
return { name: String(item._id), count: item.count };
}
// ---------------------------------------------------------------------------
// Index / overview queries
// ---------------------------------------------------------------------------
export async function getLetterYears(): Promise<YearGroup[]> { export async function getLetterYears(): Promise<YearGroup[]> {
const letters = await prisma.letter.findMany({ const raw = await bffList<BffAggregation>("/letters/years", {
where: { year: { not: null } }, tags: ["letters"],
select: { year: true },
}); });
return raw
const counts: Record<number, number> = {}; .map(mapYearGroup)
for (const l of letters) {
if (l.year) {
counts[l.year] = (counts[l.year] || 0) + 1;
}
}
return Object.entries(counts)
.map(([year, count]) => ({ year: Number(year), count }))
.filter((g) => g.year >= 1940 && g.year <= 1980) .filter((g) => g.year >= 1940 && g.year <= 1980)
.sort((a, b) => a.year - b.year); .sort((a, b) => a.year - b.year);
} }
@@ -90,67 +166,39 @@ export async function getLetterYears(): Promise<YearGroup[]> {
* Get all unique individual recipients with letter counts. * Get all unique individual recipients with letter counts.
* *
* Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]). * Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]).
* This function aggregates across all letters so that "Mukunda" appears * The BFF aggregation endpoint already aggregates across all letters so
* once with count = total letters where Mukunda is ANY recipient. * that "Mukunda" appears once with count = total letters where Mukunda
* is ANY recipient.
*/ */
export async function getLetterRecipients(): Promise<RecipientGroup[]> { export async function getLetterRecipients(): Promise<RecipientGroup[]> {
const letters = await prisma.letter.findMany({ const raw = await bffList<BffAggregation>("/letters/recipients", {
select: { recipients: true }, tags: ["letters"],
}); });
return raw.map(mapRecipientGroup).sort((a, b) => b.count - a.count);
const counts: Record<string, number> = {};
for (const l of letters) {
for (const r of l.recipients) {
counts[r] = (counts[r] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
} }
export async function getLetterLocations(): Promise<LocationGroup[]> { export async function getLetterLocations(): Promise<LocationGroup[]> {
const letters = await prisma.letter.findMany({ const raw = await bffList<BffAggregation>("/letters/locations", {
where: { location: { not: null } }, tags: ["letters"],
select: { location: true },
}); });
return raw.map(mapLocationGroup).sort((a, b) => b.count - a.count);
const counts: Record<string, number> = {};
for (const l of letters) {
if (l.location) {
counts[l.location] = (counts[l.location] || 0) + 1;
}
} }
return Object.entries(counts) // ---------------------------------------------------------------------------
.map(([name, count]) => ({ name, count })) // Letter list queries
.sort((a, b) => b.count - a.count); // ---------------------------------------------------------------------------
}
// --- Letter list queries ---
export async function getLettersByYear(year: number): Promise<LetterSummary[]> { export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({ const raw = await bffList<BffLetterSummary>(`/letters/year/${year}`, {
where: { year }, tags: ["letters"],
orderBy: [{ month: "asc" }, { day: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
}); });
return letters; return raw.map(mapLetterSummary);
} }
/** /**
* Get all letters where the given name is ANY recipient. * Get all letters where the given name is ANY recipient.
* *
* Uses Prisma's `has` filter on the recipients array. * The BFF endpoint handles the array-contains matching server-side.
* Searching for "Mukunda" returns letters addressed to: * Searching for "Mukunda" returns letters addressed to:
* - "Mukunda" (individual) * - "Mukunda" (individual)
* - "Mukunda, Janaki" (group containing Mukunda) * - "Mukunda, Janaki" (group containing Mukunda)
@@ -159,63 +207,38 @@ export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
export async function getLettersByRecipient( export async function getLettersByRecipient(
recipient: string, recipient: string,
): Promise<LetterSummary[]> { ): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({ const encoded = encodeURIComponent(recipient);
where: { recipients: { has: recipient } }, const raw = await bffList<BffLetterSummary>(`/letters/recipient/${encoded}`, {
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }], tags: ["letters"],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
}); });
return letters; return raw.map(mapLetterSummary);
} }
export async function getLettersByLocation( export async function getLettersByLocation(
location: string, location: string,
): Promise<LetterSummary[]> { ): Promise<LetterSummary[]> {
const letters = await prisma.letter.findMany({ const encoded = encodeURIComponent(location);
where: { location }, const raw = await bffList<BffLetterSummary>(`/letters/location/${encoded}`, {
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }], tags: ["letters"],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
}); });
return letters; return raw.map(mapLetterSummary);
} }
// --- Single letter --- // ---------------------------------------------------------------------------
// Single letter
// ---------------------------------------------------------------------------
export async function getLetter(slug: string): Promise<LetterFull | null> { export async function getLetter(slug: string): Promise<LetterFull | null> {
const letter = await prisma.letter.findUnique({ where: { slug } }); const letter = await bff<BffLetterFull>(`/letters/${slug}`, {
tags: ["letters"],
});
if (!letter) return null; if (!letter) return null;
return mapLetterFull(letter);
return {
slug: letter.slug,
recipients: letter.recipients,
recipientDisplay: letter.recipientDisplay,
date: letter.date,
dateRef: letter.dateRef,
year: letter.year,
month: letter.month,
day: letter.day,
location: letter.location,
salutation: letter.salutation,
paragraphs: letter.paragraphs.map(sanitizeHtml),
};
} }
// --- Navigation helpers --- // ---------------------------------------------------------------------------
// Navigation helpers
// ---------------------------------------------------------------------------
export async function getAdjacentLetters( export async function getAdjacentLetters(
slug: string, slug: string,
@@ -223,27 +246,18 @@ export async function getAdjacentLetters(
): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> { ): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> {
if (!year) return { prev: null, next: null }; if (!year) return { prev: null, next: null };
const yearLetters = await prisma.letter.findMany({ const letters = await getLettersByYear(year);
where: { year }, const idx = letters.findIndex((l) => l.slug === slug);
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
select: {
slug: true,
recipients: true,
recipientDisplay: true,
date: true,
year: true,
month: true,
location: true,
},
});
const idx = yearLetters.findIndex((l) => l.slug === slug);
return { return {
prev: idx > 0 ? yearLetters[idx - 1] : null, prev: idx > 0 ? letters[idx - 1] : null,
next: idx < yearLetters.length - 1 ? yearLetters[idx + 1] : null, next: idx < letters.length - 1 ? letters[idx + 1] : null,
}; };
} }
export async function getTotalLetterCount(): Promise<number> { export async function getTotalLetterCount(): Promise<number> {
return prisma.letter.count(); const result = await bff<{ count: number }>("/letters", {
tags: ["letters"],
});
return result?.count ?? 0;
} }
+32 -416
View File
@@ -1,429 +1,45 @@
/** /**
* Data access layer for Light of the Bhāgavata (LoB). * Data access layer for Light of the Bhāgavata (LOB).
* *
* Reads from MongoDB via Prisma, same as the rest of the app. * Delegates to the generic book data layer which calls the BFF API.
* LoB is a prose book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* It has 48 "texts" (numbered chapters) plus 4 intro segments
* (front-pages, preface, acknowledgement, the-artist).
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/lob/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
import fs from "fs"; getIntroSegments,
import path from "path"; getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "lob";
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */ export type LobMeta = BookMeta;
const NO_VOLUME = 0; export type LobChapterEntry = { number: number; title: string };
export type LobIntroSegment = IntroSegment;
export type LobChapter = Chapter;
export type LobSidebarItem = SidebarItem;
const BOOK = "lob"; export async function getLobMeta() {
return getBookMeta(CODE);
const LOB_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lob");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LobMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: LobChapterEntry[];
};
export type LobChapterEntry = {
number: number;
title: string;
};
export type LobIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type LobChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type LobSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "48", etc. */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getLobIntroSegments() {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getIntroSegments(CODE);
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getLobIntroSegment(slug: string) {
result = paragraphs as string[]; return getIntroSegment(CODE, slug);
} else {
result = [];
} }
export async function getLobChapter(chapterNum: number) {
return result.map(sanitizeHtml); return getChapter(CODE, String(chapterNum));
} }
export async function getLobContent(slug: string) {
// --------------------------------------------------------------------------- return getContent(CODE, slug);
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
} }
} export async function getLobSidebarItems() {
return getSidebarItems(CODE);
function getLocalIntroSegments(): LobIntroSegment[] {
const introDir = path.join(LOB_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: LobIntroSegment[] = [];
const files = [
"front-pages.json",
"preface.json",
"acknowledgement.json",
"the-artist.json",
];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<LobIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): LobIntroSegment | null {
const filePath = path.join(LOB_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<LobIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): LobChapter | null {
const filePath = path.join(
LOB_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<LobChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getLobMeta(): Promise<LobMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as LobMeta;
// Fallback to local JSON
const introPath = path.join(LOB_JSON_DIR, "intro.json");
return readLocalJson<LobMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getLobIntroSegments(): Promise<LobIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getLobIntroSegment(
slug: string,
): Promise<LobIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters (Texts 1–48)
// ---------------------------------------------------------------------------
/**
* A numbered text/chapter (1–48).
*/
export async function getLobChapter(
chapterNum: number,
): Promise<LobChapter | null> {
if (chapterNum < 1 || chapterNum > 48 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-preface" → intro segment
* "intro-acknowledgement" → intro segment
* "intro-the-artist" → intro segment
* "1" … "48" → chapter (text)
*/
export async function getLobContent(
slug: string,
): Promise<LobChapter | LobIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getLobIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 48) {
return getLobChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → preface → acknowledgement → the-artist → texts 1–48
*/
export async function getLobSidebarItems(): Promise<LobSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getLobMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true, order: true },
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: LobMeta,
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
): LobSidebarItem[] {
const items: LobSidebarItem[] = [];
// Intro segments (all come before chapters; LoB has no back matter)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Texts 1–48 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Text ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: LobMeta | null): LobSidebarItem[] {
const items: LobSidebarItem[] = [];
// Local intro segments (front matter only — LoB has no back matter)
const introSegs = getLocalIntroSegments();
for (const seg of introSegs) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Texts from meta or generate default 1-48
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Text ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(LOB_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<LobChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `Text ${num}: ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
} }
+92 -38
View File
@@ -1,13 +1,11 @@
/** /**
* Data access layer for Magazines (Back to Godhead, etc.). * Data access layer for Magazines (Back to Godhead, etc.).
* *
* Uses dedicated Prisma models: * Uses the BFF API instead of direct Prisma queries.
* - MagazineMeta (collection: magazine_meta) * Falls back to local JSON files when BFF is unavailable.
* - MagazineIssue (collection: magazine_issues)
*/ */
import { prisma } from "@/lib/db"; import { bff, bffList } from "@/lib/bff";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@@ -57,6 +55,35 @@ export type MagazineSidebarItem = {
type: string; type: string;
}; };
// --- BFF response types ---
type BffMagazineMeta = {
id: string;
book: string;
data: {
title: string;
author: string;
year: number | null;
description: string | null;
totalIssues: number;
totalArticles: number;
flag: string | null;
chapters: MagazineChapterEntry[];
};
};
type BffMagazineIssue = {
book: string;
volume: number | null;
part: number;
title: string;
slug: string;
paragraphs: string[];
paragraphs_json?: string;
};
// --- HTML sanitisation ---
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, ""); let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, ""); clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
@@ -71,7 +98,7 @@ function sanitizeHtml(html: string): string {
return clean; return clean;
} }
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] { function normalizeParagraphs(paragraphs: string[] | unknown): string[] {
let result: string[]; let result: string[];
if (typeof paragraphs === "string") { if (typeof paragraphs === "string") {
try { try {
@@ -88,6 +115,57 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
return result.map(sanitizeHtml); return result.map(sanitizeHtml);
} }
// --- BFF → domain mappers ---
function mapBffMetaToMagazineMetaData(raw: BffMagazineMeta): MagazineMetaData {
return {
book: raw.book,
title: raw.data.title,
author: raw.data.author,
year: raw.data.year ?? null,
description: raw.data.description ?? null,
totalIssues: raw.data.totalIssues,
totalArticles: raw.data.totalArticles,
flag: raw.data.flag ?? null,
chapters: raw.data.chapters,
};
}
function mapBffMetaToSummary(raw: BffMagazineMeta): MagazineSummary {
return {
book: raw.book,
title: raw.data.title,
author: raw.data.author,
year: raw.data.year ?? null,
description: raw.data.description ?? null,
totalIssues: raw.data.totalIssues,
flag: raw.data.flag ?? null,
};
}
function mapBffIssue(raw: BffMagazineIssue): MagazineIssueData {
// Handle paragraphs_json fallback
let paragraphs = raw.paragraphs;
if ((!paragraphs || !paragraphs.length) && raw.paragraphs_json) {
try {
paragraphs = JSON.parse(raw.paragraphs_json);
} catch {
paragraphs = [];
}
}
return {
book: raw.book,
volume: raw.volume,
part: raw.part,
title: raw.title,
slug: raw.slug,
paragraphs: normalizeParagraphs(paragraphs),
};
}
// --- Local JSON fallback helpers ---
function findLocalDirForBook(bookCode: string): string | null { function findLocalDirForBook(bookCode: string): string | null {
if (!fs.existsSync(LOCAL_JSON_DIR)) return null; if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
const entries = fs.readdirSync(LOCAL_JSON_DIR); const entries = fs.readdirSync(LOCAL_JSON_DIR);
@@ -152,22 +230,9 @@ function getLocalIssue(
export async function getAllMagazines(): Promise<MagazineSummary[]> { export async function getAllMagazines(): Promise<MagazineSummary[]> {
try { try {
const results = await prisma.magazineMeta.findMany({ const results = await bffList<BffMagazineMeta>("/magazines");
orderBy: { book: "asc" },
});
if (results.length > 0) { if (results.length > 0) {
return results.map((row) => { return results.map(mapBffMetaToSummary);
const data = row.data as unknown as MagazineMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalIssues: data.totalIssues,
flag: data.flag ?? null,
};
});
} }
} catch { } catch {
/* fall through */ /* fall through */
@@ -188,10 +253,8 @@ export async function getMagazineMeta(
bookCode: string, bookCode: string,
): Promise<MagazineMetaData | null> { ): Promise<MagazineMetaData | null> {
try { try {
const row = await prisma.magazineMeta.findUnique({ const raw = await bff<BffMagazineMeta>(`/magazines/${bookCode}`);
where: { book: bookCode }, if (raw) return mapBffMetaToMagazineMetaData(raw);
});
if (row) return row.data as unknown as MagazineMetaData;
} catch { } catch {
/* fall through */ /* fall through */
} }
@@ -204,19 +267,10 @@ export async function getMagazineIssue(
): Promise<MagazineIssueData | null> { ): Promise<MagazineIssueData | null> {
if (!slug) return null; if (!slug) return null;
try { try {
const doc = await prisma.magazineIssue.findUnique({ const raw = await bff<BffMagazineIssue>(
where: { book_slug: { book: bookCode, slug } }, `/magazines/${bookCode}/issues/${slug}`,
}); );
if (doc) { if (raw) return mapBffIssue(raw);
return {
book: doc.book,
volume: doc.volume,
part: doc.part,
title: doc.title,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
} catch { } catch {
/* fall through */ /* fall through */
} }
+26 -249
View File
@@ -1,280 +1,57 @@
/** /**
* Data access layer for Kṛṣṇa Consciousness: The Matchless Gift (MG). * Data access layer for Kṛṣṇa Consciousness: The Matchless Gift (MG).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* MG is a simple prose book with 1 intro segment (front-pages) and 7 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "mg";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "mg"; // Re-export types with book-specific names for backwards compatibility
export type MgMeta = BookMeta;
export type MgChapterEntry = { number: number; title: string };
export type MgIntroSegment = IntroSegment;
export type MgChapter = Chapter;
export type MgSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type MgMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: MgChapterEntry[];
};
export type MgChapterEntry = {
number: number;
title: string;
};
export type MgIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type MgChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type MgSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "7" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getMgMeta(): Promise<MgMeta | null> { export async function getMgMeta(): Promise<MgMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); return getBookMeta(CODE);
if (!meta) return null;
return meta.data as unknown as MgMeta;
} }
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getMgIntroSegments(): Promise<MgIntroSegment[]> { export async function getMgIntroSegments(): Promise<MgIntroSegment[]> {
const results = await prisma.proseChapter.findMany({ return getIntroSegments(CODE);
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getMgIntroSegment( export async function getMgIntroSegment(
slug: string, slug: string,
): Promise<MgIntroSegment | null> { ): Promise<MgIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form return getIntroSegment(CODE, slug);
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–7).
*/
export async function getMgChapter( export async function getMgChapter(
chapterNum: number, chapterNum: number,
): Promise<MgChapter | null> { ): Promise<MgChapter | null> {
if (chapterNum < 1 || chapterNum > 7 || !Number.isInteger(chapterNum)) { return getChapter(CODE, String(chapterNum));
return null;
} }
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "7" → chapter
*/
export async function getMgContent( export async function getMgContent(
slug: string, slug: string,
): Promise<MgChapter | MgIntroSegment | null> { ): Promise<MgChapter | MgIntroSegment | null> {
if (slug.startsWith("intro-")) { return getContent(CODE, slug);
return getMgIntroSegment(slug);
} }
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 7) {
return getMgChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–7
*/
export async function getMgSidebarItems(): Promise<MgSidebarItem[]> { export async function getMgSidebarItems(): Promise<MgSidebarItem[]> {
const [meta, allDocs] = await Promise.all([ return getSidebarItems(CODE);
getMgMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: MgSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–7 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
} }
+31 -416
View File
@@ -1,430 +1,45 @@
/** /**
* Data access layer for Message of Godhead (MOG). * Data access layer for Message of Godhead (MOG).
* *
* Reads from MongoDB via Prisma, same as the rest of the app. * Delegates to the generic book data layer which calls the BFF API.
* MOG is a prose book with no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* When the DB isn't seeded yet, falls back to reading local JSON files
* from lib/jsons/books/mog/.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
import fs from "fs"; getIntroSegments,
import path from "path"; getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "mog";
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */ export type MogMeta = BookMeta;
const NO_VOLUME = 0; export type MogChapterEntry = { number: number; title: string };
export type MogIntroSegment = IntroSegment;
export type MogChapter = Chapter;
export type MogSidebarItem = SidebarItem;
const BOOK = "mog"; export async function getMogMeta() {
return getBookMeta(CODE);
const MOG_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mog");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type MogMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: MogChapterEntry[];
};
export type MogChapterEntry = {
number: number;
title: string;
};
export type MogIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type MogChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type MogSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", "3" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getMogIntroSegments() {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getIntroSegments(CODE);
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getMogIntroSegment(slug: string) {
result = paragraphs as string[]; return getIntroSegment(CODE, slug);
} else {
result = [];
} }
export async function getMogChapter(chapterNum: number) {
return result.map(sanitizeHtml); return getChapter(CODE, String(chapterNum));
} }
export async function getMogContent(slug: string) {
// --------------------------------------------------------------------------- return getContent(CODE, slug);
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
} }
} export async function getMogSidebarItems() {
return getSidebarItems(CODE);
function getLocalIntroSegments(): MogIntroSegment[] {
const introDir = path.join(MOG_JSON_DIR, "intro");
if (!fs.existsSync(introDir)) return [];
const results: MogIntroSegment[] = [];
const files = ["front-pages.json"];
for (const file of files) {
const filePath = path.join(introDir, file);
const seg = readLocalJson<MogIntroSegment>(filePath);
if (seg) results.push(seg);
}
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function getLocalIntroSegment(rawSlug: string): MogIntroSegment | null {
const filePath = path.join(MOG_JSON_DIR, "intro", `${rawSlug}.json`);
return readLocalJson<MogIntroSegment>(filePath);
}
function getLocalChapter(chapterNum: number): MogChapter | null {
const filePath = path.join(
MOG_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<MogChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getMogMeta(): Promise<MogMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as MogMeta;
// Fallback to local JSON
const introPath = path.join(MOG_JSON_DIR, "intro.json");
return readLocalJson<MogMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getMogIntroSegments(): Promise<MogIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
if (results.length > 0) {
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
}
// Fallback to local JSON
return getLocalIntroSegments();
}
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getMogIntroSegment(
slug: string,
): Promise<MogIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (doc) {
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalIntroSegment(rawSlug);
if (!local) return null;
return {
...local,
slug: dbSlug,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–3).
*/
export async function getMogChapter(
chapterNum: number,
): Promise<MogChapter | null> {
if (chapterNum < 1 || chapterNum > 3 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "3" → chapter
*/
export async function getMogContent(
slug: string,
): Promise<MogChapter | MogIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getMogIntroSegment(slug);
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 3) {
return getMogChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–3
*/
export async function getMogSidebarItems(): Promise<MogSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getMogMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: MogMeta,
allDocs: {
slug: string;
title: string;
type: string;
chapter: number | null;
order: number | null;
}[],
): MogSidebarItem[] {
const items: MogSidebarItem[] = [];
// Intro segments (front-pages)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–3 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: MogMeta | null): MogSidebarItem[] {
const items: MogSidebarItem[] = [];
// Local intro segments (front matter)
const introSegs = getLocalIntroSegments();
for (const seg of introSegs) {
items.push({
slug: `intro-${seg.slug}`,
label: seg.title,
type: "intro-segment",
chapter: null,
});
}
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(MOG_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<MogChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `${num}. ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
} }
+34 -317
View File
@@ -1,332 +1,49 @@
/** /**
* Data access layer for The Nectar of Devotion (NOD). * Data access layer for The Nectar of Devotion (NOD).
* *
* Reads from MongoDB via Prisma, same as the rest of the app. * Delegates to the generic book data layer which calls the BFF API.
* NoD is a prose book (like KB) but has no volume structure —
* we use volume=0 as the sentinel for "no volumes".
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "nod";
// Constants
// ---------------------------------------------------------------------------
/** Volume sentinel for books without volumes. */ export type NodMeta = BookMeta;
const NO_VOLUME = 0; export type NodChapterEntry = { number: number; title: string };
export type NodIntroSegment = IntroSegment;
export type NodChapter = Chapter;
export type NodSidebarItem = SidebarItem;
const BOOK = "nod"; export async function getNodMeta() {
return getBookMeta(CODE);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type NodMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalChapters: number;
parts: NodPart[];
chapters: NodChapterEntry[];
};
export type NodPart = {
number: number;
title: string;
chapters: number[];
};
export type NodChapterEntry = {
number: number;
title: string;
};
export type NodIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type NodChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type NodSidebarItem = {
/** URL-safe slug: "intro-front-flap", "1", "51", "concluding-words" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" | "conclusion" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getNodIntroSegments() {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getIntroSegments(CODE);
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getNodIntroSegment(slug: string) {
result = paragraphs as string[]; return getIntroSegment(CODE, slug);
} else {
result = [];
} }
export async function getNodChapter(chapterNum: number) {
return result.map(sanitizeHtml); return getChapter(CODE, String(chapterNum));
} }
// NodConcludingWords is just a chapter with slug "concluding-words"
// --------------------------------------------------------------------------- export async function getNodConcludingWords() {
// Book meta return getChapter(CODE, "concluding-words");
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, parts, chapter list, etc.)
*/
export async function getNodMeta(): Promise<NodMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as NodMeta;
} }
export async function getNodContent(slug: string) {
// --------------------------------------------------------------------------- return getContent(CODE, slug);
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getNodIntroSegments(): Promise<NodIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
export async function getNodSidebarItems() {
/** return getSidebarItems(CODE);
* A single intro segment by slug.
* Accepts both "intro-preface" (URL slug) and "preface" (raw slug).
*/
export async function getNodIntroSegment(
slug: string,
): Promise<NodIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–51).
*/
export async function getNodChapter(
chapterNum: number,
): Promise<NodChapter | null> {
if (chapterNum < 1 || chapterNum > 51 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
/**
* The concluding words section.
*/
export async function getNodConcludingWords(): Promise<NodChapter | null> {
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: "concluding-words",
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-flap" → intro segment
* "intro-preface" → intro segment
* "intro-introduction" → intro segment
* "1" … "51" → chapter
* "concluding-words" → concluding section
*/
export async function getNodContent(
slug: string,
): Promise<NodChapter | NodIntroSegment | null> {
if (slug.startsWith("intro-")) {
return getNodIntroSegment(slug);
}
if (slug === "concluding-words") {
return getNodConcludingWords();
}
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 51) {
return getNodChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: intro segments → chapters 1–51 → concluding words
*/
export async function getNodSidebarItems(): Promise<NodSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getNodMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: NodSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–51 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `Chapter ${ch.number}: ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
// Concluding words
const cw = allDocs.find((d) => d.type === "conclusion");
if (cw) {
items.push({
slug: cw.slug,
label: cw.title,
type: "conclusion",
chapter: null,
});
}
return items;
} }
+45 -278
View File
@@ -1,322 +1,89 @@
/** /**
* Data access layer for The Nectar of Instruction (NoI). * Data access layer for The Nectar of Instruction (NoI).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer (BFF API) for prose intro segments,
* NoI has 11 texts (verses) plus intro segments (front-pages, preface, appendixes). * and to lib/api.ts for verse (text) lookups.
* *
* Verses were seeded with: * NoI has 11 texts (verses) plus intro segments.
* - book: "noi", division: null * Verses are stored with: book "noi", division null, chapter = text number, verse = 1.
* - chapter = text number (1-11)
* - verse = always 1
*
* Intro segments were seeded with:
* - book: "noi", volume: 0, type: "intro-segment"
* - slugs like "intro-front-pages", "intro-preface", "intro-appendixes"
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
import { getSingleVerse, type VerseRecord } from "@/lib/api";
const CODE = "noi";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Re-export types with book-specific names for backwards compatibility
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const BOOK = "noi"; export type NoiMeta = BookMeta;
const NO_VOLUME = 0; export type NoiIntroSegment = IntroSegment;
export type NoiChapter = Chapter;
// --------------------------------------------------------------------------- export type NoiSidebarItem = SidebarItem;
// Text title lookup export type NoiVerse = VerseRecord;
// ---------------------------------------------------------------------------
const TEXT_TITLES: Record<number, string> = {
1: "Text One",
2: "Text Two",
3: "Text Three",
4: "Text Four",
5: "Text Five",
6: "Text Six",
7: "Text Seven",
8: "Text Eight",
9: "Text Nine",
10: "Text Ten",
11: "Text Eleven",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type NoiMeta = {
book: string;
title: string;
subtitle: string;
author: string;
year: number;
description: string;
totalTexts: number;
texts: NoiTextEntry[];
};
export type NoiTextEntry = {
number: number;
title: string;
slug: string;
};
export type NoiIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type NoiVerse = {
book: string;
book_title: string;
text: number;
title: string;
reference: string;
devanagari: string;
transliteration: string;
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type NoiSidebarItem = {
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", etc. */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "verse" */
type: string;
/** Text number (1-11), or null for non-verse items */
text: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/iso.ts, lib/nod.ts)
// ---------------------------------------------------------------------------
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;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
/** Normalize purport via normalizeParagraphs. */
function normalizePurport(purport: string[] | JsonValue): string[] {
return normalizeParagraphs(purport);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Book meta // Book meta
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** export async function getNoiMeta() {
* Book-level metadata (title, author, texts list, etc.) return getBookMeta(CODE);
*/
export async function getNoiMeta(): Promise<NoiMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (!meta) return null;
return meta.data as unknown as NoiMeta;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Intro segments (from prose_chapters collection) // Intro segments
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** export async function getNoiIntroSegments() {
* All intro segments, ordered by their `order` field. return getIntroSegments(CODE);
*/
export async function getNoiIntroSegments(): Promise<NoiIntroSegment[]> {
const results = await prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
/** export async function getNoiIntroSegment(slug: string) {
* A single intro segment by slug. return getIntroSegment(CODE, slug);
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getNoiIntroSegment(
slug: string,
): Promise<NoiIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Verses (texts 1-11, from verses collection) // Verses (texts 1-11, from verses collection via BFF)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* A single verse/text by slug.
*
* - "1"-"11" → chapter=1–11, verse=1
*/
export async function getNoiVerse(slug: string): Promise<NoiVerse | null> { export async function getNoiVerse(slug: string): Promise<NoiVerse | null> {
const textNum = Number(slug); const num = parseInt(slug, 10);
if (!Number.isInteger(textNum) || textNum < 1 || textNum > 11) return null; if (isNaN(num)) return null;
return getSingleVerse(CODE, null, num, 1);
const doc = await prisma.verse.findFirst({
where: { book: BOOK, division: null, chapter: textNum, verse: 1 },
});
if (!doc) return null;
return {
book: doc.book,
book_title: doc.bookTitle,
text: doc.chapter, // chapter field stores the text number
title: TEXT_TITLES[doc.chapter] ?? `Text ${doc.chapter}`,
reference: doc.reference,
devanagari: doc.devanagari,
transliteration: doc.transliteration,
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
translation: sanitizeHtml(doc.translation),
purport: normalizePurport(doc.purport),
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unified content accessor // Unified content accessor
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "intro-preface" → intro segment
* "intro-appendixes" → intro segment
* "1" … "11" → verse/text
*/
export async function getNoiContent( export async function getNoiContent(
slug: string, slug: string,
): Promise<NoiVerse | NoiIntroSegment | null> { ): Promise<NoiVerse | NoiIntroSegment | NoiChapter | null> {
if (slug.startsWith("intro-")) { // Try as verse first (numeric slug)
return getNoiIntroSegment(slug); if (/^\d+$/.test(slug)) {
const verse = await getNoiVerse(slug);
if (verse) return verse;
} }
// Fall back to prose content (intro segments, etc.)
const num = Number(slug); return getContent(CODE, slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
return getNoiVerse(String(num));
}
return null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sidebar navigation // Sidebar navigation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** export async function getNoiSidebarItems() {
* Build the full sidebar navigation list. return getSidebarItems(CODE);
*
* Order: front-pages → preface → texts 1-11 → appendixes
*/
export async function getNoiSidebarItems(): Promise<NoiSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getNoiMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: NoiSidebarItem[] = [];
// 1. Intro segments that come before the texts (front-pages, preface — by order)
for (const doc of allDocs) {
if (doc.type === "intro-segment" && doc.slug !== "intro-appendixes") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
text: null,
});
}
}
// 2. Texts 1-11 (from meta.texts)
for (const t of meta.texts) {
items.push({
slug: String(t.number),
label: `${t.number}. ${t.title}`,
type: "verse",
text: t.number,
});
}
// 3. Appendixes intro segment (at the end)
const appendixes = allDocs.find((d) => d.slug === "intro-appendixes");
if (appendixes) {
items.push({
slug: appendixes.slug,
label: appendixes.title,
type: "intro-segment",
text: null,
});
}
return items;
} }
+27 -250
View File
@@ -1,280 +1,57 @@
/** /**
* Data access layer for On Chanting the Hare Kṛṣṇa Mantra (OCHKM). * Data access layer for On Chanting Hare Kṛṣṇa (OCHKM).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* OCHKM is a prose book with 1 intro segment (front-pages) and 4 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "ochkm";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "ochkm"; // Re-export types with book-specific names for backwards compatibility
export type OchkmMeta = BookMeta;
export type OchkmChapterEntry = { number: number; title: string };
export type OchkmIntroSegment = IntroSegment;
export type OchkmChapter = Chapter;
export type OchkmSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type OchkmMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: OchkmChapterEntry[];
};
export type OchkmChapterEntry = {
number: number;
title: string;
};
export type OchkmIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type OchkmChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type OchkmSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "4" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getOchkmMeta(): Promise<OchkmMeta | null> { export async function getOchkmMeta(): Promise<OchkmMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); return getBookMeta(CODE);
if (!meta) return null;
return meta.data as unknown as OchkmMeta;
} }
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getOchkmIntroSegments(): Promise<OchkmIntroSegment[]> { export async function getOchkmIntroSegments(): Promise<OchkmIntroSegment[]> {
const results = await prisma.proseChapter.findMany({ return getIntroSegments(CODE);
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getOchkmIntroSegment( export async function getOchkmIntroSegment(
slug: string, slug: string,
): Promise<OchkmIntroSegment | null> { ): Promise<OchkmIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form return getIntroSegment(CODE, slug);
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–4).
*/
export async function getOchkmChapter( export async function getOchkmChapter(
chapterNum: number, chapterNum: number,
): Promise<OchkmChapter | null> { ): Promise<OchkmChapter | null> {
if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) { return getChapter(CODE, String(chapterNum));
return null;
} }
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "4" → chapter
*/
export async function getOchkmContent( export async function getOchkmContent(
slug: string, slug: string,
): Promise<OchkmChapter | OchkmIntroSegment | null> { ): Promise<OchkmChapter | OchkmIntroSegment | null> {
if (slug.startsWith("intro-")) { return getContent(CODE, slug);
return getOchkmIntroSegment(slug);
} }
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
return getOchkmChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–4
*/
export async function getOchkmSidebarItems(): Promise<OchkmSidebarItem[]> { export async function getOchkmSidebarItems(): Promise<OchkmSidebarItem[]> {
const [meta, allDocs] = await Promise.all([ return getSidebarItems(CODE);
getOchkmMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: OchkmSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–4 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
} }
+86 -54
View File
@@ -1,18 +1,14 @@
/** /**
* Data access layer for Other Documents. * Data access layer for Other Documents.
* *
* Uses dedicated Prisma models: * Calls the BFF API for all data access (no direct Prisma usage).
* - OtherDocumentMeta (collection: other_document_meta)
* - OtherDocumentChapter (collection: other_document_chapters)
*
* Falls back to reading local JSON files from * Falls back to reading local JSON files from
* lib/jsons/other-documents/<slug>/ when the database is unavailable. * lib/jsons/other-documents/<slug>/ when the BFF is unavailable.
* *
* Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula"). * Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula").
*/ */
import { prisma } from "@/lib/db"; import { bff, bffList } from "@/lib/bff";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@@ -72,6 +68,33 @@ export type OtherDocumentSidebarItem = {
chapter: number; chapter: number;
}; };
// ---------------------------------------------------------------------------
// BFF response types (snake_case from Go)
// ---------------------------------------------------------------------------
type BffDocumentMeta = {
id: string;
book: string;
data: {
title: string;
author: string;
year?: number | null;
description?: string | null;
totalChapters: number;
chapters: { number: number; title: string }[];
};
};
type BffChapter = {
id: string;
book: string;
chapter: number;
title: string;
type: string;
slug: string;
paragraphs: string[];
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// HTML sanitisation (same pattern used across all readers) // HTML sanitisation (same pattern used across all readers)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -90,23 +113,52 @@ function sanitizeHtml(html: string): string {
return clean; return clean;
} }
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] { // ---------------------------------------------------------------------------
let result: string[]; // Mappers (BFF response → frontend types)
// ---------------------------------------------------------------------------
if (typeof paragraphs === "string") { function mapMetaData(raw: BffDocumentMeta): OtherDocumentMetaData {
try { const data =
const parsed = JSON.parse(paragraphs); raw.data && typeof raw.data === "object"
result = Array.isArray(parsed) ? parsed : [paragraphs]; ? raw.data
} catch { : ({} as BffDocumentMeta["data"]);
result = [paragraphs]; return {
} book: raw.book,
} else if (Array.isArray(paragraphs)) { title: data.title ?? "",
result = paragraphs as string[]; author: data.author ?? "",
} else { year: data.year ?? null,
result = []; description: data.description ?? null,
totalChapters: data.totalChapters ?? 0,
chapters: (data.chapters ?? []).map((ch) => ({
number: ch.number,
title: ch.title,
})),
};
} }
return result.map(sanitizeHtml); function mapSummary(raw: BffDocumentMeta): OtherDocumentSummary {
const data =
raw.data && typeof raw.data === "object"
? raw.data
: ({} as BffDocumentMeta["data"]);
return {
book: raw.book,
title: data.title ?? "",
author: data.author ?? "",
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters ?? 0,
};
}
function mapChapter(raw: BffChapter): OtherDocumentChapterData {
return {
book: raw.book,
chapter: raw.chapter,
title: raw.title,
type: raw.type,
paragraphs: (raw.paragraphs ?? []).map(sanitizeHtml),
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -195,22 +247,12 @@ function getLocalChapter(
*/ */
export async function getAllOtherDocuments(): Promise<OtherDocumentSummary[]> { export async function getAllOtherDocuments(): Promise<OtherDocumentSummary[]> {
try { try {
const results = await prisma.otherDocumentMeta.findMany({ const results = await bffList<BffDocumentMeta>("/other-documents", {
orderBy: { book: "asc" }, tags: ["other-documents"],
}); });
if (results.length > 0) { if (results.length > 0) {
return results.map((row) => { return results.map(mapSummary);
const data = row.data as unknown as OtherDocumentMetaData;
return {
book: row.book,
title: data.title,
author: data.author,
year: data.year ?? null,
description: data.description ?? null,
totalChapters: data.totalChapters,
};
});
} }
} catch { } catch {
// fall through to local JSON // fall through to local JSON
@@ -234,12 +276,12 @@ export async function getOtherDocumentMeta(
bookCode: string, bookCode: string,
): Promise<OtherDocumentMetaData | null> { ): Promise<OtherDocumentMetaData | null> {
try { try {
const row = await prisma.otherDocumentMeta.findUnique({ const raw = await bff<BffDocumentMeta>(`/other-documents/${bookCode}`, {
where: { book: bookCode }, tags: [`other-doc-${bookCode}`],
}); });
if (row) { if (raw) {
return row.data as unknown as OtherDocumentMetaData; return mapMetaData(raw);
} }
} catch { } catch {
// fall through to local JSON // fall through to local JSON
@@ -258,23 +300,13 @@ export async function getOtherDocumentChapter(
if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null; if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null;
try { try {
const doc = await prisma.otherDocumentChapter.findUnique({ const raw = await bff<BffChapter>(
where: { `/other-documents/${bookCode}/chapters/${chapterNum}`,
book_slug: { { tags: [`other-doc-${bookCode}`] },
book: bookCode, );
slug: String(chapterNum),
},
},
});
if (doc) { if (raw) {
return { return mapChapter(raw);
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
} catch { } catch {
// fall through to local JSON // fall through to local JSON
+26 -249
View File
@@ -1,280 +1,57 @@
/** /**
* Data access layer for On the Way to Kṛṣṇa (OWK). * Data access layer for On the Way to Kṛṣṇa (OWK).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* OWK is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "owk";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "owk"; // Re-export types with book-specific names for backwards compatibility
export type OwkMeta = BookMeta;
export type OwkChapterEntry = { number: number; title: string };
export type OwkIntroSegment = IntroSegment;
export type OwkChapter = Chapter;
export type OwkSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type OwkMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: OwkChapterEntry[];
};
export type OwkChapterEntry = {
number: number;
title: string;
};
export type OwkIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type OwkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type OwkSidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getOwkMeta(): Promise<OwkMeta | null> { export async function getOwkMeta(): Promise<OwkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); return getBookMeta(CODE);
if (!meta) return null;
return meta.data as unknown as OwkMeta;
} }
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getOwkIntroSegments(): Promise<OwkIntroSegment[]> { export async function getOwkIntroSegments(): Promise<OwkIntroSegment[]> {
const results = await prisma.proseChapter.findMany({ return getIntroSegments(CODE);
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getOwkIntroSegment( export async function getOwkIntroSegment(
slug: string, slug: string,
): Promise<OwkIntroSegment | null> { ): Promise<OwkIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form return getIntroSegment(CODE, slug);
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–5).
*/
export async function getOwkChapter( export async function getOwkChapter(
chapterNum: number, chapterNum: number,
): Promise<OwkChapter | null> { ): Promise<OwkChapter | null> {
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) { return getChapter(CODE, String(chapterNum));
return null;
} }
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "5" → chapter
*/
export async function getOwkContent( export async function getOwkContent(
slug: string, slug: string,
): Promise<OwkChapter | OwkIntroSegment | null> { ): Promise<OwkChapter | OwkIntroSegment | null> {
if (slug.startsWith("intro-")) { return getContent(CODE, slug);
return getOwkIntroSegment(slug);
} }
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
return getOwkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–5
*/
export async function getOwkSidebarItems(): Promise<OwkSidebarItem[]> { export async function getOwkSidebarItems(): Promise<OwkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([ return getSidebarItems(CODE);
getOwkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: OwkSidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–5 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
} }
+22 -299
View File
@@ -1,306 +1,29 @@
/** import {
* Data access layer for Prayers of King Kulaśekhara (PK). getBookMeta,
* getChapter,
* Reads from MongoDB via Prisma, same as the rest of the app. getContent,
* PK is a prose book with no volume structure and no intro segments — getSidebarItems,
* we use volume=0 as the sentinel for "no volumes". type BookMeta,
* type Chapter,
* When the DB isn't seeded yet, falls back to reading local JSON files type SidebarItem,
* from lib/jsons/books/pk/. } from "@/lib/books";
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/
import { prisma } from "@/lib/db"; const CODE = "pk";
import type { JsonValue } from "@prisma/client/runtime/library";
import fs from "fs";
import path from "path";
// --------------------------------------------------------------------------- export type PkMeta = BookMeta;
// Constants export type PkChapterEntry = { number: number; title: string };
// --------------------------------------------------------------------------- export type PkChapter = Chapter;
export type PkSidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */ export async function getPkMeta() {
const NO_VOLUME = 0; return getBookMeta(CODE);
const BOOK = "pk";
const PK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "pk");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PkMeta = {
book: string;
title: string;
author: string;
year?: number;
description: string;
totalChapters: number;
chapters: PkChapterEntry[];
};
export type PkChapterEntry = {
number: number;
title: string;
};
export type PkChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PkSidebarItem = {
/** URL-safe slug: "1", "2", … "6" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "chapter" */
type: string;
/** Chapter number */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation (mirrors lib/api.ts)
// ---------------------------------------------------------------------------
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;
} }
export async function getPkChapter(chapterNum: number) {
/** Normalize paragraphs — handles both native arrays and stringified JSON. */ return getChapter(CODE, String(chapterNum));
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
} }
} else if (Array.isArray(paragraphs)) { export async function getPkContent(slug: string) {
result = paragraphs as string[]; return getContent(CODE, slug);
} else {
result = [];
} }
export async function getPkSidebarItems() {
return result.map(sanitizeHtml); return getSidebarItems(CODE);
}
// ---------------------------------------------------------------------------
// Local JSON helpers (fallback when DB is not seeded)
// ---------------------------------------------------------------------------
function readLocalJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function getLocalChapter(chapterNum: number): PkChapter | null {
const filePath = path.join(
PK_JSON_DIR,
"chapters",
String(chapterNum).padStart(2, "0") + ".json",
);
return readLocalJson<PkChapter>(filePath);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getPkMeta(): Promise<PkMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
if (meta) return meta.data as unknown as PkMeta;
// Fallback to local JSON
const introPath = path.join(PK_JSON_DIR, "intro.json");
return readLocalJson<PkMeta>(introPath);
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–6).
*/
export async function getPkChapter(
chapterNum: number,
): Promise<PkChapter | null> {
if (chapterNum < 1 || chapterNum > 6 || !Number.isInteger(chapterNum)) {
return null;
}
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (doc) {
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// Fallback to local JSON
const local = getLocalChapter(chapterNum);
if (!local) return null;
return {
...local,
paragraphs: local.paragraphs.map(sanitizeHtml),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "1" … "6" → chapter
*/
export async function getPkContent(slug: string): Promise<PkChapter | null> {
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
return getPkChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: chapters 1–6 (no intro segments for PK)
*/
export async function getPkSidebarItems(): Promise<PkSidebarItem[]> {
const [meta, allDocs] = await Promise.all([
getPkMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: {
slug: true,
title: true,
type: true,
chapter: true,
order: true,
},
}),
]);
// If we got DB results, build from those
if (meta && allDocs.length > 0) {
return buildSidebarFromDocs(meta, allDocs);
}
// Fallback: build from local JSON
return buildSidebarFromLocal(meta);
}
function buildSidebarFromDocs(
meta: PkMeta,
_allDocs: {
slug: string;
title: string;
type: string;
chapter: number | null;
order: number | null;
}[],
): PkSidebarItem[] {
const items: PkSidebarItem[] = [];
// Chapters 1–6 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
}
function buildSidebarFromLocal(meta: PkMeta | null): PkSidebarItem[] {
const items: PkSidebarItem[] = [];
// Chapters from meta or scan local chapter JSONs
if (meta && meta.chapters) {
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
} else {
// Fallback: scan local chapter JSONs
const chaptersDir = path.join(PK_JSON_DIR, "chapters");
if (fs.existsSync(chaptersDir)) {
const files = fs
.readdirSync(chaptersDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const ch = readLocalJson<PkChapter>(path.join(chaptersDir, file));
if (ch) {
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
items.push({
slug: String(num),
label: `${num}. ${ch.title}`,
type: "chapter",
chapter: num,
});
}
}
}
}
return items;
} }
+26 -249
View File
@@ -1,280 +1,57 @@
/** /**
* Data access layer for The Perfection of Yoga (POY). * Data access layer for The Perfection of Yoga (POY).
* *
* Reads from MongoDB via Prisma. * Delegates to the generic book data layer which calls the BFF API.
* POY is a prose book with 1 intro segment (front-pages) and 11 chapters.
*
* Collections used:
* - book_meta (via Prisma BookMeta model)
* - prose_chapters (via Prisma ProseChapter model)
*/ */
import { prisma } from "@/lib/db"; import {
import type { JsonValue } from "@prisma/client/runtime/library"; getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
type IntroSegment,
type Chapter,
type SidebarItem,
} from "@/lib/books";
// --------------------------------------------------------------------------- const CODE = "poy";
// Constants
// ---------------------------------------------------------------------------
const BOOK = "poy"; // Re-export types with book-specific names for backwards compatibility
export type PoyMeta = BookMeta;
export type PoyChapterEntry = { number: number; title: string };
export type PoyIntroSegment = IntroSegment;
export type PoyChapter = Chapter;
export type PoySidebarItem = SidebarItem;
/** Volume sentinel for books without volumes. */
const NO_VOLUME = 0;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PoyMeta = {
book: string;
title: string;
author: string;
year: number;
description: string;
totalChapters: number;
chapters: PoyChapterEntry[];
};
export type PoyChapterEntry = {
number: number;
title: string;
};
export type PoyIntroSegment = {
slug: string;
title: string;
type: string;
book: string;
order: number;
paragraphs: string[];
};
export type PoyChapter = {
book: string;
chapter: number | null;
title: string;
type: string;
slug?: string;
paragraphs: string[];
};
/** Union type used in sidebar / table-of-contents navigation. */
export type PoySidebarItem = {
/** URL-safe slug: "intro-front-pages", "1", "2", … "11" */
slug: string;
/** Human-readable label for the sidebar */
label: string;
/** "intro-segment" | "chapter" */
type: string;
/** Chapter number, or null for non-chapter items */
chapter: number | null;
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
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;
}
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Book-level metadata (title, author, chapter list, etc.)
*/
export async function getPoyMeta(): Promise<PoyMeta | null> { export async function getPoyMeta(): Promise<PoyMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } }); return getBookMeta(CODE);
if (!meta) return null;
return meta.data as unknown as PoyMeta;
} }
// ---------------------------------------------------------------------------
// Intro segments
// ---------------------------------------------------------------------------
/**
* All intro segments, ordered by their `order` field.
*/
export async function getPoyIntroSegments(): Promise<PoyIntroSegment[]> { export async function getPoyIntroSegments(): Promise<PoyIntroSegment[]> {
const results = await prisma.proseChapter.findMany({ return getIntroSegments(CODE);
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
orderBy: { order: "asc" },
});
return results.map((doc) => ({
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
}));
} }
/**
* A single intro segment by slug.
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
*/
export async function getPoyIntroSegment( export async function getPoyIntroSegment(
slug: string, slug: string,
): Promise<PoyIntroSegment | null> { ): Promise<PoyIntroSegment | null> {
// DB stores with "intro-" prefix; accept either form return getIntroSegment(CODE, slug);
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
},
});
if (!doc) return null;
return {
slug: doc.slug,
title: doc.title,
type: doc.type,
book: doc.book,
order: doc.order,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
} }
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
/**
* A numbered chapter (1–11).
*/
export async function getPoyChapter( export async function getPoyChapter(
chapterNum: number, chapterNum: number,
): Promise<PoyChapter | null> { ): Promise<PoyChapter | null> {
if (chapterNum < 1 || chapterNum > 11 || !Number.isInteger(chapterNum)) { return getChapter(CODE, String(chapterNum));
return null;
} }
const doc = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: {
book: BOOK,
volume: NO_VOLUME,
slug: String(chapterNum),
},
},
});
if (!doc) return null;
return {
book: doc.book,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
paragraphs: normalizeParagraphs(doc.paragraphs),
};
}
// ---------------------------------------------------------------------------
// Unified content accessor
// ---------------------------------------------------------------------------
/**
* Get content by URL slug.
*
* "intro-front-pages" → intro segment
* "1" … "11" → chapter
*/
export async function getPoyContent( export async function getPoyContent(
slug: string, slug: string,
): Promise<PoyChapter | PoyIntroSegment | null> { ): Promise<PoyChapter | PoyIntroSegment | null> {
if (slug.startsWith("intro-")) { return getContent(CODE, slug);
return getPoyIntroSegment(slug);
} }
const num = Number(slug);
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
return getPoyChapter(num);
}
return null;
}
// ---------------------------------------------------------------------------
// Sidebar navigation
// ---------------------------------------------------------------------------
/**
* Build the full sidebar navigation list.
*
* Order: front-pages → chapters 1–11
*/
export async function getPoySidebarItems(): Promise<PoySidebarItem[]> { export async function getPoySidebarItems(): Promise<PoySidebarItem[]> {
const [meta, allDocs] = await Promise.all([ return getSidebarItems(CODE);
getPoyMeta(),
prisma.proseChapter.findMany({
where: { book: BOOK, volume: NO_VOLUME },
orderBy: { order: "asc" },
select: { slug: true, title: true, type: true, chapter: true },
}),
]);
if (!meta) return [];
const items: PoySidebarItem[] = [];
// Intro segments (from DB, in order)
for (const doc of allDocs) {
if (doc.type === "intro-segment") {
items.push({
slug: doc.slug,
label: doc.title,
type: "intro-segment",
chapter: null,
});
}
}
// Chapters 1–11 (use meta.chapters for consistent title/ordering)
for (const ch of meta.chapters) {
items.push({
slug: String(ch.number),
label: `${ch.number}. ${ch.title}`,
type: "chapter",
chapter: ch.number,
});
}
return items;
} }

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