Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd1d2e724 | ||
|
|
179b7d8862 | ||
|
|
7294d9e15b | ||
|
|
e3df40559b | ||
|
|
9a3056fbd8 | ||
|
|
db8deb232b | ||
|
|
730efb1bb3 | ||
|
|
0106d8cee2 | ||
|
|
d917b3ea7d | ||
|
|
7c07b33e4b | ||
|
|
96a83fe1cb | ||
|
|
befcc44980 | ||
|
|
e45d5eb02c | ||
|
|
09fbcf93ed | ||
|
|
4cbb146bc1 | ||
|
|
bf1ff47d3e | ||
|
|
9c72ce814d | ||
|
|
32da38b24f | ||
|
|
5951eb5e18 | ||
|
|
ab1517f32f | ||
|
|
326ba090ca |
+257
-212
@@ -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
|
||||
- **next-themes** for dark/light/system mode
|
||||
- **vaul** for mobile drawer navigation
|
||||
- **Prisma v6** with MongoDB (provider `prisma-client-js`)
|
||||
- **MongoDB** native driver (`mongodb` package) for seeding (Prisma requires replica set for writes)
|
||||
- **Go BFF API** — all data fetched via `lib/bff.ts` HTTP client calling the Go BFF server (no direct database access)
|
||||
- **framer-motion** for audio player animations (spring enter/exit, minimize/expand transitions, audio wave bars)
|
||||
- **Typesense** (via BFF) — typo-tolerant full-text search across all content
|
||||
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
|
||||
- **Database**: MongoDB at `DATABASE_URL` env variable with `?authSource=admin`
|
||||
|
||||
---
|
||||
|
||||
@@ -64,10 +64,19 @@ knowledgebase/
|
||||
│ ├── disciples/ → 4,747 initiated disciples directory
|
||||
│ │ ├── page.tsx → Disciples browser (letter nav, suffix filter, search, sort, pagination)
|
||||
│ │ └── 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/
|
||||
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
|
||||
│ ├── books/ → Book/verse API routes
|
||||
│ ├── search/route.ts → BFF search proxy (Typesense via /api/v1/search)
|
||||
│ └── glossary/route.ts → Glossary API
|
||||
├── components/
|
||||
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer)
|
||||
@@ -82,26 +91,31 @@ knowledgebase/
|
||||
│ ├── page-header.tsx → Shared header with ReadingSettings (max-w-5xl)
|
||||
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
|
||||
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
|
||||
│ ├── header-search.tsx → Header search button with framer-motion expand animation
|
||||
│ ├── site-footer.tsx → Global footer (About + Home links)
|
||||
│ ├── audio-player/ → Site-wide floating audio player
|
||||
│ │ ├── audio-context.tsx → React context + provider, manages <audio> element
|
||||
│ │ ├── floating-player.tsx → Floating player UI (desktop bar / mobile pill)
|
||||
│ │ ├── audio-wave.tsx → Reusable animated audio wave bars
|
||||
│ │ └── index.ts → Barrel exports
|
||||
│ ├── logo.tsx → Theme-aware logo component
|
||||
│ └── theme-provider.tsx → next-themes wrapper
|
||||
├── lib/
|
||||
│ ├── db.ts → Prisma client singleton
|
||||
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary)
|
||||
│ ├── bff.ts → BFF API client (typed fetch wrapper: bff<T>(), bffList<T>(), bffOrThrow<T>())
|
||||
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary) — calls BFF API
|
||||
│ ├── books.ts → Generic book data layer (replaces 29 individual book files)
|
||||
│ ├── constants.ts → Shared constants (BOOK_NAMES, CC_LILAS)
|
||||
│ ├── og.ts → OG image metadata builders (ogForVerse, ogForLetter, etc.)
|
||||
│ ├── text-highlight.ts → Text fragment encode/decode, highlight matching
|
||||
│ ├── source-reference.ts → Source reference from URL (for copy attribution)
|
||||
│ ├── disciples.ts → Disciples data access (by letter, place, year, search)
|
||||
│ ├── letters.ts → Letters data access (by year, recipient, location)
|
||||
│ ├── disciples.ts → Disciples data access — calls BFF API
|
||||
│ ├── letters.ts → Letters data access (by year, recipient, location) — calls BFF API
|
||||
│ ├── transcriptions.ts → Transcriptions data access — calls BFF API
|
||||
│ ├── utils.ts → cn() helper
|
||||
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.)
|
||||
│ ├── other-documents.ts → Other Documents data access
|
||||
│ ├── journals.ts → Journals data access
|
||||
│ └── magazines.ts → Magazines data access
|
||||
├── prisma/
|
||||
│ └── schema.prisma → All models (Verse, BookMeta, ProseChapter, Essay, Letter,
|
||||
│ GlossaryEntry, Disciple, Transcription, etc.)
|
||||
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.) — calls BFF API
|
||||
│ ├── other-documents.ts → Other Documents data access — calls BFF API
|
||||
│ ├── journals.ts → Journals data access — calls BFF API
|
||||
│ └── magazines.ts → Magazines data access — calls BFF API
|
||||
└── public/
|
||||
├── fonts/Noto_Sans/static/ → NotoSans-Regular.ttf + NotoSans-Bold.ttf (OG images)
|
||||
├── images/ → OG card image (Prabhupāda)
|
||||
@@ -121,199 +135,50 @@ extractor/ → Extraction & seeding tools (not deployed)
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (Prisma + MongoDB)
|
||||
## Data Architecture
|
||||
|
||||
```prisma
|
||||
type Synonym {
|
||||
word String
|
||||
meanings String[]
|
||||
}
|
||||
The knowledgebase **never connects to MongoDB directly**. All data is served by the **Go BFF API** server.
|
||||
|
||||
model Verse {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String // "bg" | "sb" | "cc" | "sb1962" | "iso" | "noi"
|
||||
division String? // null for BG/SB1962/ISO/NOI; canto for SB; lila for CC
|
||||
chapter Int
|
||||
verse Int
|
||||
verseEnd Int?
|
||||
reference String
|
||||
bookTitle String
|
||||
devanagari String
|
||||
bengali String? // only CC has Bengali
|
||||
transliteration String
|
||||
synonyms Synonym[]
|
||||
translation String
|
||||
purport String[]
|
||||
### BFF Server
|
||||
|
||||
@@unique([book, division, chapter, verse])
|
||||
@@index([book])
|
||||
@@index([book, division])
|
||||
@@index([book, division, chapter])
|
||||
@@map("verses")
|
||||
}
|
||||
- **URL**: Configured via `BFF_URL` environment variable (default: `http://localhost:8080`)
|
||||
- The BFF reads from MongoDB — the knowledgebase frontend has no database driver or connection string
|
||||
- All API endpoints live under `/api/v1/...` on the BFF server
|
||||
|
||||
model BookMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
### BFF Client (`lib/bff.ts`)
|
||||
|
||||
@@map("book_meta")
|
||||
}
|
||||
The typed fetch wrapper provides three helpers:
|
||||
|
||||
model ProseChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
volume Int // 0 = no volumes (sentinel for non-volume books)
|
||||
chapter Int? // null for intro segments
|
||||
title String
|
||||
type String // "chapter" | "intro-segment" | "conclusion"
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
- **`bff<T>(path, options?)`** — Fetch JSON from the BFF. Returns `null` on 404, throws `BffError` on other failures.
|
||||
- **`bffList<T>(path, options?)`** — Fetch a list. Returns `[]` on 404 (instead of null).
|
||||
- **`bffOrThrow<T>(path, options?)`** — Fetch JSON, throwing on 404. Use when a missing resource should be an error.
|
||||
|
||||
@@unique([book, volume, slug])
|
||||
@@index([book])
|
||||
@@index([book, volume])
|
||||
@@index([book, volume, type])
|
||||
@@map("prose_chapters")
|
||||
}
|
||||
Options include `params` (query parameters), `revalidate` (Next.js ISR seconds, default 60), and `tags` (cache tags).
|
||||
|
||||
model Essay {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
slug String @unique
|
||||
title String
|
||||
author String
|
||||
year Int?
|
||||
group String? // e.g., "two-essays"
|
||||
order Int
|
||||
paragraphs String[]
|
||||
### Response Envelope
|
||||
|
||||
@@index([group])
|
||||
@@map("essays")
|
||||
}
|
||||
BFF responses use a `{ success: true, data: ... }` envelope. The `bff()` client auto-unwraps this — callers receive the inner `data` value directly.
|
||||
|
||||
model OtherDocumentMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
### Case Mapping
|
||||
|
||||
@@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 {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
chapter Int
|
||||
title String
|
||||
type String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
### Data Layer Files
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@map("other_document_chapters")
|
||||
}
|
||||
Individual `lib/*.ts` files are thin wrappers that:
|
||||
1. Call a BFF endpoint via `bff()` / `bffList()` / `bffOrThrow()`
|
||||
2. Map the snake_case response to camelCase frontend types
|
||||
3. Export typed functions consumed by Next.js server components
|
||||
|
||||
model JournalMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
|
||||
@@map("journal_meta")
|
||||
}
|
||||
|
||||
model JournalChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
chapter Int
|
||||
title String
|
||||
type String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@map("journal_chapters")
|
||||
}
|
||||
|
||||
model MagazineMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
|
||||
@@map("magazine_meta")
|
||||
}
|
||||
|
||||
model MagazineIssue {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
volume Int
|
||||
part Int
|
||||
title String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
paragraphsJson String? // JSON string fallback for large issues (BSON wire protocol workaround)
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@index([book, volume])
|
||||
@@map("magazine_issues")
|
||||
}
|
||||
|
||||
model Letter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
slug String @unique
|
||||
recipients String[] // array of individual recipient names
|
||||
recipientDisplay String // original combined string for display
|
||||
date String?
|
||||
dateRef String?
|
||||
year Int?
|
||||
month Int?
|
||||
day Int?
|
||||
location String?
|
||||
salutation String?
|
||||
paragraphs String[]
|
||||
|
||||
@@index([year])
|
||||
@@index([recipients])
|
||||
@@index([location])
|
||||
@@index([year, month])
|
||||
@@map("letters")
|
||||
}
|
||||
|
||||
model GlossaryEntry {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
term String
|
||||
definition String
|
||||
definitionHtml String
|
||||
|
||||
@@unique([term])
|
||||
@@map("glossary")
|
||||
}
|
||||
|
||||
model Disciple {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
initiatedName String
|
||||
suffix String // "Dasa" | "Devi Dasi" | "Sw"
|
||||
initiationDate DateTime? // 1st of the month (exact day unknown unless verified)
|
||||
initiationPlace String?
|
||||
karmiName String? // pre-initiation (birth) name
|
||||
avatar String? // URL to profile photo
|
||||
about String? // biographical notes
|
||||
tags String[]
|
||||
isVerified Boolean @default(false)
|
||||
|
||||
@@index([initiatedName])
|
||||
@@index([initiationPlace])
|
||||
@@index([initiationDate])
|
||||
@@index([suffix])
|
||||
@@index([tags])
|
||||
@@map("disciples")
|
||||
}
|
||||
```
|
||||
Key data layers and their BFF endpoint prefixes:
|
||||
- `lib/api.ts` → `/api/v1/books/*`, `/api/v1/glossary/*` — Books, verses, prose chapters, glossary
|
||||
- `lib/books.ts` → `/api/v1/books/{code}/*` — Generic book data layer (replaces 29 individual per-book files)
|
||||
- `lib/letters.ts` → `/api/v1/letters/*` — Letters (by year, recipient, location)
|
||||
- `lib/transcriptions.ts` → `/api/v1/transcriptions/*` — Transcriptions (by year, place, type)
|
||||
- `lib/disciples.ts` → `/api/v1/disciples/*` — Disciples directory
|
||||
- `lib/other-documents.ts` → `/api/v1/other-documents/*` — Other Documents
|
||||
- `lib/journals.ts` → `/api/v1/journals/*` — Journals
|
||||
- `lib/magazines.ts` → `/api/v1/magazines/*` — Magazines
|
||||
|
||||
---
|
||||
|
||||
@@ -459,15 +324,29 @@ Seed script: `npx tsx scripts/seed-disciples.ts`
|
||||
**Schema notes**:
|
||||
- `recipients` is an array of individual names (e.g., `["Mukunda", "Janaki"]`)
|
||||
- `recipientDisplay` is the original combined string (e.g., `"Mukunda, Janaki"`)
|
||||
- Searching for a recipient uses Prisma `{ has: name }` to match any letter where that person appears
|
||||
- Searching for a recipient filters via the BFF endpoint (matches any letter where that person appears)
|
||||
- Multi-recipient groups are split by `,`, `;`, ` and `, ` & `
|
||||
- Trailing punctuation (`:`, `,`, `.`) cleaned from names
|
||||
|
||||
### Transcriptions (stored in `transcriptions` collection)
|
||||
|
||||
3,787 transcriptions of Śrīla Prabhupāda's lectures and conversations (1966–1977), plus 59 philosophy discussions and 12 SB dictations.
|
||||
|
||||
- **Lectures** (2,179): Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, Nectar of Devotion, Īśopaniṣad, Brahma-saṁhitā, and general lectures
|
||||
- **Conversations** (1,537): Morning walks, room conversations, interviews, press conferences, initiations
|
||||
- **Philosophy Discussions** (59): Western philosophers discussed with Śyāmasundara and Hayagrīva
|
||||
- **SB Dictations** (12): Śrīmad-Bhāgavatam 3rd Canto translation/purport dictations
|
||||
- **Audio**: 99.1% have linked audio recordings from Cloudflare R2 (`audioUrl` field)
|
||||
- **Empty content**: 30 transcriptions (Hindi/Bengali lectures) have no English text but may have audio
|
||||
|
||||
Data layer: `lib/transcriptions.ts` (calls BFF API, NOT local JSON files or direct MongoDB)
|
||||
|
||||
### Other
|
||||
|
||||
| Content | Collection |
|
||||
|---------|-----------|
|
||||
| Glossary (2,749 entries) | `glossary` |
|
||||
| Transcriptions (3,787 entries) | `transcriptions` collection |
|
||||
|
||||
---
|
||||
|
||||
@@ -582,6 +461,27 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
|
||||
| `/letters/location/Los%20Angeles` | All letters from a specific location |
|
||||
| `/letters/[slug]` | Individual letter reader with prev/next navigation |
|
||||
|
||||
### Transcriptions
|
||||
|
||||
```
|
||||
/transcriptions → Browse by year / place / type
|
||||
/transcriptions?view=year → Year grid view
|
||||
/transcriptions?view=place → Place grid view
|
||||
/transcriptions?view=type → Lecture vs Conversation cards
|
||||
/transcriptions/year/[year] → Year listing, grouped by month
|
||||
/transcriptions/place/[place] → Place listing
|
||||
/transcriptions/type/[type] → Type listing (lecture/conversation), year sidebar
|
||||
/transcriptions/[slug] → Individual transcription reading page
|
||||
```
|
||||
|
||||
Each transcription page shows:
|
||||
- Title, type badge (Lecture/Conversation), date, place, reference, duration
|
||||
- Participants list
|
||||
- Play Audio button (links to Cloudflare R2 mp3)
|
||||
- Content blocks: paragraphs with speaker labels, verses (centered italic), quotes (border-left), notes
|
||||
- Prev/next navigation within same year
|
||||
- `?hl=` text highlight sharing support with OG image
|
||||
|
||||
### Other routes
|
||||
|
||||
| Route | Content |
|
||||
@@ -635,11 +535,17 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
|
||||
### Reading Settings (accessibility)
|
||||
- **Theme**: Light / Dark / System toggle
|
||||
- **Palette**: 3 light + 3 dark colour palettes, each selectable from a swatch grid in the settings panel
|
||||
- Light: **Aged Parchment** (warm cream/saffron — default), **Tulasī Garden** (sage green/emerald), **Yamunā Mist** (sky-blue/cerulean)
|
||||
- Dark: **Ancient Manuscript** (warm brown/saffron — default), **Nīlamaṇi** (deep navy/sky-blue — Kṛṣṇa's "blue jewel"), **Vraja Twilight** (deep wine/rose-red)
|
||||
- Swatches in the settings panel show only the palettes for the currently resolved theme (uses `resolvedTheme` from next-themes, so System mode resolves correctly)
|
||||
- Each palette defines a full set of CSS variables (background, foreground, primary, card, muted, accent, border, etc.) so the entire UI adapts cohesively
|
||||
- Palette CSS lives in `app/globals.css` under `:root:not(.dark)[data-light-palette="..."]` and `.dark[data-dark-palette="..."]` selectors. The `:not(.dark)` scoping prevents light-palette attribute rules from overriding `.dark` (an attribute selector has higher specificity than a plain class selector)
|
||||
- **Serif Font**: Toggle to switch reading content to Georgia/serif
|
||||
- **Font Size**: Slider (XS → XXL) using CSS `zoom` on `main` and `aside`
|
||||
- **Justify Text**: Toggle for justified text alignment on reading content
|
||||
- Settings persist in `localStorage` under `spsays_reading_prefs`
|
||||
- Applied via CSS classes on `<html>` element
|
||||
- Settings persist in `localStorage` under `spsays_reading_prefs` (now includes `lightPalette` and `darkPalette` fields)
|
||||
- Applied via CSS classes (`reading-serif`, `reading-fs-*`, `reading-justified`) AND data attributes (`data-light-palette`, `data-dark-palette`) on `<html>` element
|
||||
|
||||
### Mobile Navigation
|
||||
- Left-slide drawer (vaul) triggered by a menu button next to the Previous button
|
||||
@@ -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
|
||||
- Implemented in all 24+ reader pages via shared `MobileNav` component
|
||||
|
||||
### Page Header (`components/page-header.tsx`)
|
||||
- Shared client component used on every page — wraps the title/breadcrumb area and the right-side actions (search button + reading settings)
|
||||
- **Mobile layout** uses CSS `float-right` on the actions block so the title text genuinely wraps around them on the first line and uses the **full container width** on every subsequent line. (CSS `padding-right` was previously used but applies the same gap to every line, leaving an empty column down the right edge.)
|
||||
- When the header search is expanded on mobile, the actions block drops the float, the logo/title area is hidden via `max-md:hidden`, and the search bar fills the full header width edge-to-edge (no `ml-2` margin in the expanded state)
|
||||
- **Desktop layout** is unchanged — standard `md:flex md:items-start md:justify-between md:gap-4` with the actions block on the right (`md:order-1 md:shrink-0`)
|
||||
- Reads a sessionStorage flag (`spsays_from_search`) on first render via a lazy `useState` initialiser; if set, renders a “← Back to search results” button that calls `router.back()` and **hides the page's own first-child `<Link>` breadcrumb** via `[&>a:first-child]:hidden` so there's never a double-arrow stack
|
||||
- The lazy initialiser also clears the flag on read (one-shot), so navigating from the result page to a subsequent page (e.g. next verse) doesn't show the search return button again
|
||||
|
||||
### Search
|
||||
|
||||
**Search** (`/search` page) — Typo-tolerant, diacritic-insensitive full-text search across all content.
|
||||
|
||||
- **Powered by Typesense** via the BFF (`/api/v1/search` proxied through `/api/search`)
|
||||
- **9 indexed content types** combined into 8 user-facing filters: Books (verses + prose), Letters, Transcriptions, Glossary, Essays, Journals, Magazines, Other Documents
|
||||
- **Books filter** combines verses and prose into one tab (sends `type=verses,prose` to the BFF)
|
||||
- **Multi-select filters** — clicking multiple type tabs combines them (e.g., Books + Letters); clicking "All" clears all filters
|
||||
- **URL state** — `q`, `type`, and `page` are synced to the URL (e.g. `/search?q=foo&type=books,letters&page=2`), so any search is shareable and bookmarkable
|
||||
- **Pagination** — appears both above the results (compact: prev/next + page count) and below them
|
||||
- **Result cards** — the entire card is a clickable `Link` that navigates to the source page
|
||||
- **Header search** — `HeaderSearchButton` on every page header expands into a search input on click; the `/` keyboard shortcut opens the search input from any page
|
||||
- **300ms debounce** on input
|
||||
- **Highlighted snippets** — matched text wrapped in `<mark>` tags
|
||||
- **Typo tolerance** — "Krishna", "Krsna", "Krishn" all find Kṛṣṇa results
|
||||
- **Diacritic insensitivity** — `_ascii` shadow fields indexed alongside originals (e.g., "Yashodanandana" matches "Yaśodanandana")
|
||||
- **Prefix matching** — partial words like "sarv dharman parit" match `sarva-dharmān parityajya`
|
||||
- **Hyphenated terms** — every collection (not just verses) has `token_separators: ["-"]`, so `Dīkṣā-guru` indexes as two tokens (`dīkṣā` + `guru`) and a query for "diksa guru" matches it cleanly
|
||||
- **Stopwords** — a server-side Typesense stopwords set (`common`) strips conversational tokens like `letter`, `to`, `of`, `essay`, `magazine`, etc. from queries so they don't drown out specific intent. e.g. "letter to tusta krsna" effectively searches for "tusta krsna" and surfaces actual letters to that recipient instead of any letter whose body contains "letter" + "to"
|
||||
- **Exact > prefix > typo ranking** — `prioritize_exact_match=true` + `prioritize_token_position=true` so "karma" outranks "karmic", and exact recipient matches outrank typo matches
|
||||
- **Glossary results** — glossary pages read `?term=` from the URL so a search result lands on the correct term (with diacritic-insensitive matching)
|
||||
- **Defensive null handling** — the frontend always treats a null `results` payload as an empty array
|
||||
- Files: `app/search/page.tsx`, `app/search/search-content.tsx`, `app/api/search/route.ts`, `components/header-search.tsx`
|
||||
|
||||
**Header search behaviour.** When the user submits a search from the inline header input, `HeaderSearchButton` automatically appends a `type` query param matching the section they're currently in (via `usePathname()`):
|
||||
|
||||
| Current path | Search URL gets | Active tab |
|
||||
|---|---|---|
|
||||
| `/books/**` | `type=books` | Books |
|
||||
| `/letters/**` | `type=letters` | Letters |
|
||||
| `/transcriptions/**` | `type=transcriptions` | Transcriptions |
|
||||
| `/essays/**` | `type=essays` | Essays |
|
||||
| `/glossary/**` | `type=glossary` | Glossary |
|
||||
| `/journals/**` | `type=journals` | Journals |
|
||||
| `/magazines/**` | `type=magazines` | Magazines |
|
||||
| `/other-documents/**` | `type=other_documents` | Other Documents |
|
||||
| `/`, `/search`, `/about` | no `type` param | All |
|
||||
|
||||
**Header search expand UX.** The collapsed icon expands into a 400px-wide bar on desktop (capped via `md:max-w-100`, animated to `width: 100%` of its container by framer-motion) and a full-width bar on mobile. The bar has a “Close” X on the left, the text input in the middle, and a primary-coloured “Search ⏎” submit button on the right — the `CornerDownLeft` icon hints that Enter also submits. Pressing Esc collapses. Spring animation on enter, instant collapse (no exit animation, no AnimatePresence — the previous use of `AnimatePresence mode="popLayout"` caused a backwards-shrink flash because `popLayout` pulled the exiting element out of flow and ran a layout-level FLIP).
|
||||
|
||||
**Back to search results.** When a user clicks a result card, `ResultCard`'s `onClick` writes a one-shot `spsays_from_search` flag to sessionStorage. On the destination page, `PageHeader` reads (and consumes) this flag in a lazy `useState` initialiser; if present, it replaces the page's own breadcrumb link with a “← Back to search results” button that calls `router.back()`. Client-side navigation preserves `AudioPlayerProvider` so audio playback continues uninterrupted.
|
||||
|
||||
**Search-result highlight.** Each result card builds its destination URL through `buildSearchResultUrl(result.url, result.snippet)` from `lib/text-highlight.ts`. This extracts the text inside the snippet's `<mark>` tags (from first mark to last mark, HTML stripped), encodes it via the existing `encodeTextFragment()` helper, and appends it as `?hl=...`. The destination page's existing `TextHighlighter` then finds and highlights the matched phrase — reusing the exact same `?hl=` infrastructure as share links, with no changes to `TextHighlighter` itself.
|
||||
|
||||
**Search relevance.** Search is tuned to find verses where the verse text itself contains the query, not commentaries that cite it. Verse purports are intentionally excluded from the default search so results stay focused on primary sources. In practice this means searching for `sarva dharman parityajya` returns BG 18.66 first (the actual verse) rather than the many commentaries that quote it.
|
||||
|
||||
### Per-Page Filters
|
||||
- Books page: diacritic-aware search (typing "Krsna" matches "Kṛṣṇa")
|
||||
- Glossary page: full-text search across terms and definitions
|
||||
- Uses Unicode NFD normalization + manual IAST character stripping
|
||||
|
||||
### Color Palette
|
||||
- **Light mode ("Aged Parchment")**: Warm cream backgrounds, deep sepia text, saffron/ochre primary
|
||||
- **Dark mode ("Ancient Manuscript")**: Deep warm brown backgrounds, parchment text, golden saffron primary
|
||||
### Color Palettes
|
||||
|
||||
Three themes per mode, all named after concepts from the Vedic tradition. Each defines a complete set of CSS variables (background, foreground, primary, card, muted, accent, border, ring, sidebar variants, charts) for cohesive styling site-wide.
|
||||
|
||||
**Light palettes**
|
||||
- **Aged Parchment** (default) — warm cream backgrounds, deep sepia text, saffron/ochre primary
|
||||
- **Tulasī Garden** — sage-green backgrounds, deep forest text, emerald primary; inspired by the sacred Tulasī plant
|
||||
- **Yamunā Mist** — soft blue backgrounds, deep teal text, cerulean primary; inspired by the sacred Yamunā river
|
||||
|
||||
**Dark palettes**
|
||||
- **Ancient Manuscript** (default) — deep warm brown backgrounds, parchment text, golden saffron primary
|
||||
- **Nīlamaṇi** — deep navy backgrounds, soft moonlight text, sky-blue primary; “blue jewel,” one of Kṛṣṇa's epithets
|
||||
- **Vraja Twilight** — deep wine backgrounds, warm cream text, rose-red primary; the crimson dusk over Vraja
|
||||
|
||||
Palettes are switched independently of theme: the user can pick their preferred light palette AND their preferred dark palette — the right one takes effect automatically when next-themes toggles between modes. Selection happens in `components/reading-settings.tsx` via a 3-up grid of swatch buttons (each swatch is a paint-chip-style square with the palette's background colour plus a small primary-coloured dot in the corner).
|
||||
|
||||
### Text Selection Toolbar
|
||||
- Global floating toolbar appears on text selection across all reading pages (except RTFs)
|
||||
@@ -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
|
||||
- Case-insensitive matching for both DOM highlighting and server-side text extraction
|
||||
- Cross-paragraph matching via position mapping (spaces inserted at element boundaries)
|
||||
- **HTML-entity regex must be strict.** `extractMatchedText()` strips HTML entities from paragraph strings before searching. The regex matches only real entity shapes — named (`&`), decimal (`'`), and hex (`'`) — not the loose `&[^;]+;` pattern, which would pair a stray `&` (e.g. "24 & 25 Oct") with a far-away `;` (e.g. "my beloved son;") and eat everything between them, breaking OG quote extraction for any selection that spans such a stray ampersand. The browser-side `findAndHighlightText` is unaffected because it walks DOM text nodes that the browser has already decoded.
|
||||
- `buildSearchResultUrl(resultUrl, snippetHtml)` extracts the matched text from a search snippet's `<mark>` tags and appends `?hl=...` to the result URL so the destination page highlights the match — same infrastructure as share links.
|
||||
|
||||
### Dynamic OG Images
|
||||
- `/api/og` route generates 1200×630 PNG images using `next/og` (Satori)
|
||||
@@ -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
|
||||
- When `?hl=` is present, the actual selected text is extracted from DB content for the OG quote
|
||||
|
||||
### Audio Player
|
||||
|
||||
A site-wide floating audio player for transcription recordings (and future music library). Built with framer-motion for animations.
|
||||
|
||||
- **Desktop**: Centered floating bar (max-width 680px) with cover image, track info, transport controls (prev/play/next), volume slider, source link, close button
|
||||
- **Mobile**: Full player shows two rows: track info row + centered transport controls
|
||||
- **Auto-minimize**: After 8 seconds of inactivity on **both mobile and desktop**, the player auto-minimizes to a floating pill. The minimized pill shows cover image with a circular progress ring and audio wave animation. Tap/click to expand.
|
||||
- **X button behavior**: Minimizes the player if audio is playing; stops playback entirely if audio is paused
|
||||
- **Clicking outside the player**: Minimizes it (does not stop playback)
|
||||
- **Z-index**: `z-40` — below text selection toolbar (z-80), drawers (z-90), modals (z-100)
|
||||
- **Hidden when selection toolbar is active** on mobile to avoid overlap
|
||||
- **Forced dark surface** (`bg-[#1c1c1e]`) for accessibility contrast regardless of theme
|
||||
- **Keyboard shortcuts**: Space (play/pause), ←/→ (seek ±10s), ↑/↓ (volume), M (mute), Esc (close)
|
||||
- **Progress seeker**: 8px tall bar at top of player, click/drag to seek
|
||||
- **Volume**: Persisted to localStorage. Mobile relies on hardware volume buttons
|
||||
- **framer-motion**: Spring animations for enter/exit, AnimatePresence for minimize/expand transitions, motion.div for audio wave bars
|
||||
|
||||
Components:
|
||||
- `components/audio-player/audio-context.tsx` — React context + provider, manages `<audio>` element
|
||||
- `components/audio-player/floating-player.tsx` — Floating player UI
|
||||
- `components/audio-player/audio-wave.tsx` — Reusable animated audio wave bars
|
||||
- `components/audio-player/index.ts` — Barrel exports
|
||||
|
||||
The `AudioPlayerProvider` wraps the entire app in `layout.tsx`. The `FloatingPlayer` is rendered before `SiteFooter`.
|
||||
|
||||
### Site Footer
|
||||
- Global footer on every page with © year, About link, Home link
|
||||
|
||||
@@ -696,30 +695,32 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin
|
||||
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org
|
||||
BFF_URL=http://localhost:8080 # Go BFF API server URL (default: http://localhost:8080)
|
||||
ALLOW_RTF=true # Enable RTF viewer (development only)
|
||||
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org # Public site URL for OG images
|
||||
```
|
||||
|
||||
> No new vars are needed for the frontend search feature — search goes through the BFF, which has `TYPESENSE_URL` and `TYPESENSE_API_KEY` configured server-side.
|
||||
|
||||
---
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Development (from knowledgebase/)
|
||||
npm run dev
|
||||
# 1. Start the Go BFF server (required — all data comes from the BFF)
|
||||
cd spsays-bff && go run cmd/api/main.go # Starts on :8080 by default
|
||||
|
||||
# Prisma
|
||||
npx prisma generate # Regenerate Prisma client after schema changes
|
||||
# 2. Development (from knowledgebase/)
|
||||
npm run dev # Next.js dev server with Turbopack
|
||||
|
||||
# Build
|
||||
# 3. Build
|
||||
npx next build
|
||||
|
||||
# Extraction & seeding scripts are in extractor/ — see extractor/scripts/
|
||||
|
||||
# BFF API server (from ../spsays-bff/)
|
||||
make run # Build + start Go API on :8080
|
||||
```
|
||||
|
||||
> **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)
|
||||
@@ -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.
|
||||
|
||||
## Transcription JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "720911sb-los-angeles",
|
||||
"name": "Śrīmad-Bhāgavatam 1.2.6",
|
||||
"type": "lecture",
|
||||
"date": "September 11, 1972",
|
||||
"year": 1972,
|
||||
"month": 9,
|
||||
"day": 11,
|
||||
"place": "Los Angeles",
|
||||
"context": null,
|
||||
"reference": "SB 1.2.6",
|
||||
"durationMinutes": 45,
|
||||
"participants": ["Prabhupāda", "Devotees"],
|
||||
"audioUrl": "https://s3.prabhupadasays.com/sp-audio/...",
|
||||
"content": [
|
||||
{
|
||||
"text": "So Kṛṣṇa consciousness movement...",
|
||||
"format": "paragraph",
|
||||
"alignment": "left",
|
||||
"speaker": "Prabhupāda"
|
||||
},
|
||||
{
|
||||
"text": "<i>oṁ namo bhagavate...</i>",
|
||||
"format": "verse",
|
||||
"alignment": "center",
|
||||
"speaker": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `"quote"` (translation/purport), `"heading"` (TEXT/TRANSLATION/PURPORT labels), `"note"` (editorial notes).
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **MongoDB BSON wire corruption**: The MongoDB server occasionally corrupts large BSON documents. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time.
|
||||
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.
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
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
@@ -4,16 +4,18 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import {
|
||||
BookOpen,
|
||||
Users,
|
||||
BrainCircuit,
|
||||
Blocks,
|
||||
Circle,
|
||||
CheckCircle2,
|
||||
CircleCheck,
|
||||
Clock
|
||||
} from "lucide-react";
|
||||
|
||||
const PHASES = [
|
||||
{
|
||||
title: "Phase I — The Vedabase",
|
||||
status: "in-progress" as const,
|
||||
status: "done" as const,
|
||||
icon: BookOpen,
|
||||
description:
|
||||
"Indexing the complete pre-1980s Vedabase into a structured, searchable database — books, letters, essays, magazines, journals, glossary, and disciple records — with a clean, accessible reading interface.",
|
||||
@@ -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,
|
||||
icon: BrainCircuit,
|
||||
description:
|
||||
@@ -38,7 +54,7 @@ const PHASES = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Phase III — Decentralized Preservation",
|
||||
title: "Phase IV — Decentralized Preservation",
|
||||
status: "upcoming" as const,
|
||||
icon: Blocks,
|
||||
description:
|
||||
@@ -134,7 +150,7 @@ export default function AboutPage() {
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">Roadmap</h2>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Our work is organized into three phases — each building on the
|
||||
Our work is organized into four phases — each building on the
|
||||
last.
|
||||
</p>
|
||||
</div>
|
||||
@@ -145,13 +161,16 @@ export default function AboutPage() {
|
||||
|
||||
{PHASES.map((phase, i) => {
|
||||
const Icon = phase.icon;
|
||||
const isDone = phase.status === "done";
|
||||
const isInProgress = phase.status === "in-progress";
|
||||
|
||||
return (
|
||||
<div key={i} className="relative flex gap-5 pb-10 last:pb-0">
|
||||
{/* Timeline node */}
|
||||
<div className="relative z-10 flex items-center justify-center size-10 shrink-0 rounded-full bg-background ring-1 ring-border">
|
||||
{isInProgress ? (
|
||||
{isDone ? (
|
||||
<Icon className="size-4 text-primary" />
|
||||
) : isInProgress ? (
|
||||
<div className="relative">
|
||||
<Icon className="size-4 text-primary" />
|
||||
<span className="absolute -top-1 -right-1 size-2.5 rounded-full bg-primary animate-pulse" />
|
||||
@@ -167,9 +186,14 @@ export default function AboutPage() {
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{phase.title}
|
||||
</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">
|
||||
<CheckCircle2 className="size-3" />
|
||||
<Clock className="size-3" />
|
||||
In Progress
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ book: string }> }
|
||||
{ params }: { params: Promise<{ book: string }> },
|
||||
) {
|
||||
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 });
|
||||
return NextResponse.json({ book: meta.book, ...meta.data as Record<string, unknown> });
|
||||
return NextResponse.json(meta);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getSingleVerse,
|
||||
getChapterVerseIndex,
|
||||
getChapterVerses,
|
||||
} from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
@@ -7,7 +11,7 @@ export async function GET(
|
||||
) {
|
||||
const { book } = await params;
|
||||
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 verseStr = url.searchParams.get("verse");
|
||||
const indexOnly = url.searchParams.get("indexOnly") === "true";
|
||||
@@ -22,20 +26,18 @@ export async function GET(
|
||||
}
|
||||
|
||||
const chapter = parseInt(chapterStr);
|
||||
const where: Record<string, unknown> = { book, chapter };
|
||||
|
||||
if (book === "bg") {
|
||||
where.division = null;
|
||||
} else if (division) {
|
||||
where.division = division;
|
||||
}
|
||||
const effectiveDivision = book === "bg" ? null : division;
|
||||
|
||||
// --- Single verse mode: ?verse=N ---
|
||||
if (verseStr) {
|
||||
const verseNum = parseInt(verseStr);
|
||||
const verse = await prisma.verse.findFirst({
|
||||
where: { ...where, verse: verseNum },
|
||||
});
|
||||
const verse = await getSingleVerse(
|
||||
book,
|
||||
effectiveDivision,
|
||||
chapter,
|
||||
verseNum,
|
||||
);
|
||||
|
||||
if (!verse) {
|
||||
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 ---
|
||||
if (indexOnly) {
|
||||
const verses = await prisma.verse.findMany({
|
||||
where,
|
||||
orderBy: { verse: "asc" },
|
||||
select: {
|
||||
verse: true,
|
||||
verseEnd: true,
|
||||
reference: true,
|
||||
},
|
||||
});
|
||||
const verses = await getChapterVerseIndex(book, effectiveDivision, chapter);
|
||||
|
||||
return NextResponse.json({ verses, total: verses.length });
|
||||
}
|
||||
@@ -62,24 +56,14 @@ export async function GET(
|
||||
// --- Paginated mode: return full verse data in pages ---
|
||||
const page = Math.max(1, parseInt(pageStr || "1"));
|
||||
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [verses, total] = await Promise.all([
|
||||
prisma.verse.findMany({
|
||||
where,
|
||||
orderBy: { verse: "asc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.verse.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
verses,
|
||||
total,
|
||||
const result = await getChapterVerses(
|
||||
book,
|
||||
effectiveDivision,
|
||||
chapter,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasMore: skip + verses.length < total,
|
||||
});
|
||||
);
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAllBooks } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
const metas = await prisma.bookMeta.findMany();
|
||||
return NextResponse.json(metas.map((m) => ({ book: m.book, ...m.data as Record<string, unknown> })));
|
||||
const books = await getAllBooks();
|
||||
return NextResponse.json(books);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAllGlossaryEntries } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
const entries = await prisma.glossaryEntry.findMany({
|
||||
orderBy: { term: "asc" },
|
||||
});
|
||||
const entries = await getAllGlossaryEntries();
|
||||
return NextResponse.json(entries);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ export default async function BbdPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -162,14 +162,7 @@ function SidebarLink({
|
||||
item: BkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -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" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search books..."
|
||||
placeholder="Search books by name..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
||||
@@ -118,7 +118,6 @@ export default async function BsPage() {
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -133,7 +132,6 @@ export default async function BsPage() {
|
||||
{foreword.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{foreword.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -162,14 +162,7 @@ function SidebarLink({
|
||||
item: CbhcSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -63,7 +63,6 @@ export default async function DsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -63,7 +63,6 @@ export default async function EjPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function EkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -217,14 +217,7 @@ function SidebarLink({
|
||||
item: GgSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function GgPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -126,7 +125,6 @@ export default async function GgPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function HkhPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -50,8 +50,11 @@ export async function generateMetadata({
|
||||
const isVerse_ = content && "mantra" in content && "devanagari" in content;
|
||||
const paragraphs = content
|
||||
? 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;
|
||||
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
@@ -64,7 +67,7 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
const title = isVerse_
|
||||
? `${(content as IsoVerse).reference}: ${(content as IsoVerse).title}`
|
||||
? `${(content as unknown as IsoVerse).reference}`
|
||||
: content && "title" in content
|
||||
? (content as IsoIntroSegment).title
|
||||
: `Mantra ${slug}`;
|
||||
@@ -110,7 +113,7 @@ export default async function IsoMantraPage({
|
||||
{verse && (
|
||||
<>
|
||||
<span className="text-primary">{verse.reference}: </span>
|
||||
{verse.title}
|
||||
{verse.bookTitle}
|
||||
</>
|
||||
)}
|
||||
{intro && intro.title}
|
||||
|
||||
@@ -68,7 +68,6 @@ export default async function IsoPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{frontPages.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -85,7 +84,6 @@ export default async function IsoPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -164,7 +162,6 @@ export default async function IsoPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{aboutAuthor.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -72,7 +72,6 @@ export default async function KbVolumePage({
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -106,7 +105,6 @@ export default async function KbVolumePage({
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{ch.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -221,14 +221,7 @@ function SidebarLink({
|
||||
item: LclSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function LclPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -126,7 +125,6 @@ export default async function LclPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function LobPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function MgPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -116,7 +116,6 @@ export default async function MmPage() {
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function MogPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -116,7 +116,6 @@ export default async function NbsPage() {
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -203,14 +203,7 @@ function SidebarLink({
|
||||
item: NodSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -70,7 +70,6 @@ export default async function NodPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -65,7 +65,6 @@ export default async function NoiPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{frontPages.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -82,7 +81,6 @@ export default async function NoiPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{preface.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -141,7 +139,6 @@ export default async function NoiPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{appendixes.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function OchkmPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function OwkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function PoyPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -239,14 +239,7 @@ function SidebarLink({
|
||||
item: PqpaSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function PqpaPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -151,7 +150,6 @@ export default async function PqpaPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function RvPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function SsrPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -123,7 +122,6 @@ export default async function SsrPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function SvaPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -123,7 +122,6 @@ export default async function SvaPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -141,6 +141,31 @@ function TlcSidebar({
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
// Group chapters by part (if parts exist)
|
||||
const hasParts = chapterItems.some((item) => item.partNumber != null);
|
||||
|
||||
const partGroups: {
|
||||
partNumber: number;
|
||||
partTitle: string;
|
||||
chapters: TlcSidebarItem[];
|
||||
}[] = [];
|
||||
if (hasParts) {
|
||||
const partMap = new Map<
|
||||
number,
|
||||
{ partTitle: string; chapters: TlcSidebarItem[] }
|
||||
>();
|
||||
for (const item of chapterItems) {
|
||||
const pn = item.partNumber ?? 0;
|
||||
const pt = item.partTitle ?? `Part ${pn}`;
|
||||
if (!partMap.has(pn)) partMap.set(pn, { partTitle: pt, chapters: [] });
|
||||
partMap.get(pn)!.chapters.push(item);
|
||||
}
|
||||
for (const [partNumber, { partTitle, chapters }] of partMap) {
|
||||
partGroups.push({ partNumber, partTitle, chapters });
|
||||
}
|
||||
partGroups.sort((a, b) => a.partNumber - b.partNumber);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
@@ -161,8 +186,25 @@ function TlcSidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
{/* Chapters — grouped by part if available */}
|
||||
{hasParts
|
||||
? partGroups.map((group) => (
|
||||
<div key={group.partNumber} className="mb-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
{group.partTitle}
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{group.chapters.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
))
|
||||
: chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
@@ -189,14 +231,7 @@ function SidebarLink({
|
||||
item: TlcSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -68,7 +68,6 @@ export default async function TlcPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -226,14 +226,7 @@ function SidebarLink({
|
||||
item: TlkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function TlkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -126,7 +125,6 @@ export default async function TlkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -226,14 +226,7 @@ function SidebarLink({
|
||||
item: TqkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
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;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function TqkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -126,7 +125,6 @@ export default async function TqkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
+11
-27
@@ -10,8 +10,7 @@ import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import { bff, bffList } from "@/lib/bff";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (same pattern used across all readers)
|
||||
@@ -31,25 +30,6 @@ function sanitizeHtml(html: string): string {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -69,7 +49,14 @@ type SidebarItem = {
|
||||
};
|
||||
|
||||
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;
|
||||
return {
|
||||
slug: doc.slug,
|
||||
@@ -77,15 +64,12 @@ async function getEssay(slug: string): Promise<EssayData | null> {
|
||||
author: doc.author,
|
||||
year: doc.year,
|
||||
group: doc.group,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
paragraphs: (doc.paragraphs ?? []).map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
async function getEssaySidebar(): Promise<SidebarItem[]> {
|
||||
return prisma.essay.findMany({
|
||||
orderBy: [{ group: "asc" }, { order: "asc" }],
|
||||
select: { slug: true, title: true },
|
||||
});
|
||||
return bffList<SidebarItem>("/essays");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search essays..."
|
||||
placeholder="Search essays by title..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
||||
+7
-5
@@ -4,7 +4,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bffList } from "@/lib/bff";
|
||||
import { EssaysGrid } from "./essays-grid";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
@@ -17,10 +17,12 @@ export function generateMetadata(): Metadata {
|
||||
}
|
||||
|
||||
async function getAllEssays() {
|
||||
return prisma.essay.findMany({
|
||||
orderBy: [{ group: "asc" }, { order: "asc" }],
|
||||
select: { slug: true, title: true, year: true, group: true },
|
||||
});
|
||||
return bffList<{
|
||||
slug: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
group: string | null;
|
||||
}>("/essays");
|
||||
}
|
||||
|
||||
export default async function EssaysPage() {
|
||||
|
||||
+144
@@ -89,6 +89,78 @@
|
||||
--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 {
|
||||
--background: oklch(0.197 0.013 55.5);
|
||||
@@ -124,6 +196,78 @@
|
||||
--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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -291,8 +291,28 @@ export function GlossaryClient({
|
||||
letterCounts,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
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);
|
||||
|
||||
// All term names
|
||||
|
||||
@@ -82,7 +82,7 @@ export function OtherDocumentsGrid({
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
placeholder="Search documents by title..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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'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'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 “{debouncedQuery}”.
|
||||
<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
|
||||
“
|
||||
{effectiveData.query}”
|
||||
</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"
|
||||
>
|
||||
← 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getTranscription,
|
||||
getAdjacentTranscriptions,
|
||||
} from "@/lib/transcriptions";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -18,25 +18,49 @@ import { PlayAudioButton } from "./play-audio-button";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const t = await getTranscription(slug);
|
||||
if (!t) return {};
|
||||
|
||||
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({
|
||||
title: t.name,
|
||||
detail: detail || undefined,
|
||||
quote:
|
||||
t.content.length > 0
|
||||
? t.content
|
||||
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
|
||||
?.text?.replace(/<[^>]+>/g, "")
|
||||
.slice(0, 200)
|
||||
: undefined,
|
||||
quote: spQuote,
|
||||
});
|
||||
}
|
||||
|
||||
return ogForTranscription(`Transcripts: ${t.name}`, detail || undefined);
|
||||
}
|
||||
|
||||
export default async function TranscriptionPage({
|
||||
@@ -93,16 +117,16 @@ export default async function TranscriptionPage({
|
||||
<TranscriptionNav prev={prev} next={next} />
|
||||
|
||||
{/* 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 */}
|
||||
{t.participants.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex pl-2 items-center gap-2 flex-wrap text-sm text-muted-foreground">
|
||||
<div className="">
|
||||
<div className="flex flex-row pl-2 mb-2 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
<span>Participants:</span>
|
||||
</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) => (
|
||||
<Badge
|
||||
key={i}
|
||||
@@ -119,7 +143,7 @@ export default async function TranscriptionPage({
|
||||
|
||||
{/* Play Audio button */}
|
||||
{t.audioUrl && (
|
||||
<div className="w-full md:w-auto flex justify-end">
|
||||
<div className="w-full md:w-[25%] flex justify-end">
|
||||
<PlayAudioButton
|
||||
audioUrl={t.audioUrl}
|
||||
title={t.name}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"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 { Play, Pause, Radio } from "lucide-react";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
type PlayAudioButtonProps = {
|
||||
audioUrl: string;
|
||||
@@ -63,7 +67,11 @@ export function PlayAudioButton({
|
||||
aria-label={isThisPlaying ? "Pause" : "Play audio"}
|
||||
>
|
||||
{isThisPlaying ? (
|
||||
<Pause className="size-3.5" />
|
||||
<AudioWaveAnimation
|
||||
paused={false}
|
||||
colorClassName="bg-primary-foreground"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Play className="size-3.5 ml-0.5" />
|
||||
)}
|
||||
@@ -85,7 +93,11 @@ export function PlayAudioButton({
|
||||
>
|
||||
{isThisPlaying ? (
|
||||
<>
|
||||
<Radio className="size-4 animate-pulse" />
|
||||
<AudioWaveAnimation
|
||||
paused={false}
|
||||
colorClassName="bg-primary-foreground"
|
||||
size="sm"
|
||||
/>
|
||||
<span>Now Playing</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@ export const dynamic = "force-dynamic";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { ogForTranscriptions } from "@/lib/og";
|
||||
import {
|
||||
getTranscriptionYears,
|
||||
getTranscriptionPlaces,
|
||||
@@ -14,12 +14,7 @@ import { TranscriptionsNav } from "./transcriptions-nav";
|
||||
import { TranscriptionsBrowser } from "./transcriptions-browser";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return buildOgMeta({
|
||||
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.",
|
||||
});
|
||||
return ogForTranscriptions();
|
||||
}
|
||||
|
||||
export default async function TranscriptionsPage({
|
||||
|
||||
@@ -8,7 +8,8 @@ import { buildOgMeta } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
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({
|
||||
params,
|
||||
@@ -60,7 +61,7 @@ export default async function TranscriptionsByPlacePage({
|
||||
<Link
|
||||
key={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="flex items-center gap-2 flex-wrap">
|
||||
@@ -87,6 +88,16 @@ export default async function TranscriptionsByPlacePage({
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { buttonVariants } from "@/components/ui/button";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -34,6 +35,7 @@ export type TranscriptionItem = {
|
||||
reference: string | null;
|
||||
durationMinutes: number | null;
|
||||
participants: string[];
|
||||
audioUrl: string | null;
|
||||
};
|
||||
|
||||
type YearGroup = {
|
||||
@@ -364,7 +366,7 @@ function MonthSection({
|
||||
<li key={t.slug}>
|
||||
<Link
|
||||
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="flex items-center gap-2 flex-wrap">
|
||||
@@ -396,6 +398,16 @@ function MonthSection({
|
||||
)}
|
||||
</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>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -17,8 +17,8 @@ export async function generateMetadata({
|
||||
const transcriptions = await getTranscriptionsByYear(year);
|
||||
return buildOgMeta({
|
||||
title: "Transcriptions",
|
||||
detail: `${transcriptions.length} transcriptions from ${year}`,
|
||||
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year}.`,
|
||||
detail: `${transcriptions.length} lectures & conversations from ${year}`,
|
||||
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year} — the living voice of the Founder-Ācārya preserved in text.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useRef, useCallback, useEffect, useState } from "react";
|
||||
import { useAudioPlayer } from "./audio-context";
|
||||
import { AudioWaveAnimation } from "./audio-wave";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
@@ -18,6 +20,64 @@ import {
|
||||
Loader2,
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -230,6 +290,28 @@ export function FloatingPlayer() {
|
||||
} = useAudioPlayer();
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -240,47 +322,81 @@ export function FloatingPlayer() {
|
||||
};
|
||||
}, [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
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
iTogglePlayPause();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
seekTo(Math.max(0, currentTime - 10));
|
||||
iSeekTo(Math.max(0, currentTime - 10));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
seekTo(Math.min(duration, currentTime + 10));
|
||||
iSeekTo(Math.min(duration, currentTime + 10));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setVolume(Math.min(1, volume + 0.05));
|
||||
iSetVolume(Math.min(1, volume + 0.05));
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setVolume(Math.max(0, volume - 0.05));
|
||||
iSetVolume(Math.max(0, volume - 0.05));
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
iToggleMute();
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
stop();
|
||||
iStop();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
stop,
|
||||
iTogglePlayPause,
|
||||
iSeekTo,
|
||||
iSetVolume,
|
||||
iToggleMute,
|
||||
iStop,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
@@ -292,18 +408,105 @@ export function FloatingPlayer() {
|
||||
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
|
||||
const hasPrev = currentIndex > 0;
|
||||
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 (
|
||||
<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}
|
||||
data-audio-player
|
||||
role="region"
|
||||
aria-label="Audio player"
|
||||
tabIndex={0}
|
||||
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(
|
||||
// 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
|
||||
"w-[calc(100%-2rem)] max-w-[680px]",
|
||||
// Forced dark surface for contrast regardless of theme
|
||||
@@ -311,8 +514,6 @@ export function FloatingPlayer() {
|
||||
"border border-white/10",
|
||||
"rounded-2xl",
|
||||
"shadow-2xl shadow-black/25",
|
||||
// Animation
|
||||
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300",
|
||||
// Clip seeker to rounded top corners
|
||||
"overflow-hidden",
|
||||
)}
|
||||
@@ -321,7 +522,7 @@ export function FloatingPlayer() {
|
||||
<ProgressBar
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={seekTo}
|
||||
onSeek={iSeekTo}
|
||||
thin
|
||||
/>
|
||||
|
||||
@@ -364,7 +565,7 @@ export function FloatingPlayer() {
|
||||
{/* Center: Transport controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TransportButton
|
||||
onClick={previous}
|
||||
onClick={iPrevious}
|
||||
disabled={!hasPrev && currentTime <= 3}
|
||||
label="Previous track"
|
||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||
@@ -373,7 +574,7 @@ export function FloatingPlayer() {
|
||||
</TransportButton>
|
||||
|
||||
<TransportButton
|
||||
onClick={togglePlayPause}
|
||||
onClick={iTogglePlayPause}
|
||||
label={isPlaying ? "Pause" : "Play"}
|
||||
size="large"
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
@@ -388,7 +589,7 @@ export function FloatingPlayer() {
|
||||
</TransportButton>
|
||||
|
||||
<TransportButton
|
||||
onClick={next}
|
||||
onClick={iNext}
|
||||
disabled={!hasNext}
|
||||
label="Next track"
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
onClick={iToggleMute}
|
||||
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"
|
||||
>
|
||||
@@ -419,7 +620,7 @@ export function FloatingPlayer() {
|
||||
max={100}
|
||||
step={1}
|
||||
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"
|
||||
className={cn(
|
||||
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
|
||||
@@ -448,11 +649,11 @@ export function FloatingPlayer() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Close */}
|
||||
{/* Minimize (playing) or Close (paused) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
aria-label="Close player"
|
||||
onClick={isPlaying ? () => setMinimized(true) : iStop}
|
||||
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"
|
||||
>
|
||||
<X className="size-4" />
|
||||
@@ -461,7 +662,10 @@ export function FloatingPlayer() {
|
||||
</div>
|
||||
|
||||
{/* ── 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 */}
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
||||
@@ -508,11 +712,11 @@ export function FloatingPlayer() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Close */}
|
||||
{/* Minimize (playing) or Close (paused) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
aria-label="Close player"
|
||||
onClick={isPlaying ? () => setMinimized(true) : iStop}
|
||||
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"
|
||||
>
|
||||
<X className="size-4" />
|
||||
@@ -522,7 +726,7 @@ export function FloatingPlayer() {
|
||||
{/* Row 2: Prev + Play/Pause + Next — centered */}
|
||||
<div className="flex items-center justify-center gap-4 py-1">
|
||||
<TransportButton
|
||||
onClick={previous}
|
||||
onClick={iPrevious}
|
||||
disabled={!hasPrev && currentTime <= 3}
|
||||
label="Previous track"
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
||||
@@ -531,7 +735,7 @@ export function FloatingPlayer() {
|
||||
</TransportButton>
|
||||
|
||||
<TransportButton
|
||||
onClick={togglePlayPause}
|
||||
onClick={iTogglePlayPause}
|
||||
label={isPlaying ? "Pause" : "Play"}
|
||||
size="large"
|
||||
className="bg-white text-black hover:bg-white/90 !size-12"
|
||||
@@ -546,7 +750,7 @@ export function FloatingPlayer() {
|
||||
</TransportButton>
|
||||
|
||||
<TransportButton
|
||||
onClick={next}
|
||||
onClick={iNext}
|
||||
disabled={!hasNext}
|
||||
label="Next track"
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
||||
@@ -555,6 +759,8 @@ export function FloatingPlayer() {
|
||||
</TransportButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
|
||||
export type { AudioTrack } from "./audio-context";
|
||||
export { FloatingPlayer } from "./floating-player";
|
||||
export { AudioWaveAnimation } from "./audio-wave";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { HeaderSearchButton } from "@/components/header-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
sticky = false,
|
||||
@@ -10,6 +17,29 @@ export function PageHeader({
|
||||
sticky?: boolean;
|
||||
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 (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -18,12 +48,68 @@ export function PageHeader({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
<div className="shrink-0 pt-1">
|
||||
{/*
|
||||
Layout strategy:
|
||||
- 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 />
|
||||
</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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,18 +17,88 @@ function useMounted() {
|
||||
);
|
||||
}
|
||||
|
||||
type LightPalette = "aged-parchment" | "tulasi-garden" | "yamuna-mist";
|
||||
type DarkPalette = "ancient-manuscript" | "nilamani" | "vraja-twilight";
|
||||
|
||||
type ReadingPrefs = {
|
||||
serifFont: boolean;
|
||||
fontSize: string;
|
||||
justified: boolean;
|
||||
lightPalette: LightPalette;
|
||||
darkPalette: DarkPalette;
|
||||
};
|
||||
|
||||
const DEFAULT_PREFS: ReadingPrefs = {
|
||||
serifFont: false,
|
||||
fontSize: "base",
|
||||
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 = [
|
||||
{ value: "xs", label: "XS" },
|
||||
{ value: "sm", label: "S" },
|
||||
@@ -58,6 +128,12 @@ function loadPrefs(): ReadingPrefs {
|
||||
typeof parsed.justified === "boolean"
|
||||
? parsed.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 {
|
||||
return DEFAULT_PREFS;
|
||||
@@ -86,6 +162,11 @@ function applyClasses(prefs: ReadingPrefs) {
|
||||
|
||||
// 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({
|
||||
@@ -128,7 +209,7 @@ export function ReadingSettings() {
|
||||
const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs());
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const mounted = useMounted();
|
||||
|
||||
// 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 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) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
|
||||
@@ -188,16 +277,20 @@ export function ReadingSettings() {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
<button
|
||||
ref={buttonRef as React.Ref<HTMLButtonElement>}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Reading settings"
|
||||
aria-expanded={open}
|
||||
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" />
|
||||
</Button>
|
||||
<Settings className="size-5 md:size-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
@@ -235,6 +328,54 @@ export function ReadingSettings() {
|
||||
</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">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Reading
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCopyFeedback, type Position } from "./shared";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bookmark, FolderPlus, Link2, AtSign, Check } from "lucide-react";
|
||||
import { buildShortHighlightUrl } from "@/lib/text-highlight";
|
||||
import { Bookmark, FolderPlus, AtSign, Link2, Check } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -35,6 +34,18 @@ export function MorePopover({
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
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(() => {
|
||||
if (!open || isMobile) return;
|
||||
@@ -71,14 +82,14 @@ export function MorePopover({
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
label: copied ? "Link Copied!" : "Copy Link",
|
||||
description: "Copy a link to this page",
|
||||
icon: copied ? (
|
||||
<Check className="size-5" />
|
||||
) : (
|
||||
<Link2 className="size-5" />
|
||||
),
|
||||
onClick: () => copy(buildHighlightUrl(selectedText)),
|
||||
label: copied
|
||||
? "Link Copied!"
|
||||
: generatingLink
|
||||
? "Generating link…"
|
||||
: "Copy Link",
|
||||
description: "Copy a shareable link to this quote",
|
||||
icon: copied ? <Check className="size-5" /> : <Link2 className="size-5" />,
|
||||
onClick: handleCopyLink,
|
||||
},
|
||||
{
|
||||
key: "mention",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCopyFeedback } from "./shared";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { buildShortHighlightUrl } from "@/lib/text-highlight";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, MessageSquare, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
@@ -24,6 +25,18 @@ export function ShareModal({
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
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 = [
|
||||
{
|
||||
@@ -41,12 +54,17 @@ export function ShareModal({
|
||||
label: "Share Link",
|
||||
icon: <ExternalLink className="size-4" />,
|
||||
description: "Copy a shareable link",
|
||||
onClick: () => {
|
||||
copy(buildHighlightUrl(selectedText));
|
||||
},
|
||||
onClick: handleCopyShareLink,
|
||||
},
|
||||
];
|
||||
|
||||
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) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -72,7 +90,7 @@ export function ShareModal({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
|
||||
{linkLabel(opt.key, opt.label)}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{opt.description}</p>
|
||||
</div>
|
||||
@@ -130,7 +148,7 @@ export function ShareModal({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
|
||||
{linkLabel(opt.key, opt.label)}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{opt.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ export function SiteFooter() {
|
||||
<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">
|
||||
<p>
|
||||
© {new Date().getFullYear()}{" "}
|
||||
Team {" "}
|
||||
<span className="font-medium text-foreground">Prabhupāda Says</span>
|
||||
</p>
|
||||
|
||||
|
||||
+222
-127
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Data access layer for server components.
|
||||
*
|
||||
* These functions query MongoDB via Prisma and are called directly by
|
||||
* Next.js server components. The /api/* routes exist for external consumers
|
||||
* and for client-side verse-navigation fetching.
|
||||
* These functions call the Go BFF API server instead of querying the DB
|
||||
* directly. The BFF handles sanitization, pagination, and caching.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import { bff, bffList, bffOrThrow } from "@/lib/bff";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -33,6 +31,12 @@ export type VerseRecord = {
|
||||
synonyms: { word: string; meanings: string[] }[];
|
||||
translation: 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 = {
|
||||
@@ -73,28 +77,133 @@ export type ProseChapterRecord = {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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[]> {
|
||||
const metas = await prisma.bookMeta.findMany();
|
||||
return metas.map((m) => ({
|
||||
book: m.book,
|
||||
...(m.data as Record<string, unknown>),
|
||||
}));
|
||||
const raw = await bffList<{ book: string; data: Record<string, unknown> }>(
|
||||
"/books",
|
||||
{ tags: ["books"] },
|
||||
);
|
||||
return raw.map(flattenBookMeta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single book's meta by slug ("bg", "sb", "cc").
|
||||
*/
|
||||
export async function getBookMeta(book: string): Promise<BookMeta | null> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book } });
|
||||
if (!meta) return null;
|
||||
return { book: meta.book, ...(meta.data as Record<string, unknown>) };
|
||||
const raw = await bff<{ book: string; data: Record<string, unknown> }>(
|
||||
`/books/${book}`,
|
||||
{ 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,
|
||||
chapter: number,
|
||||
): Promise<VerseIndexEntry[]> {
|
||||
const entries = await prisma.verse.findMany({
|
||||
where: { book, division, chapter },
|
||||
orderBy: { verse: "asc" },
|
||||
select: {
|
||||
verse: true,
|
||||
verseEnd: true,
|
||||
reference: true,
|
||||
},
|
||||
const path = versePath(book, division, chapter);
|
||||
const res = await bff<{
|
||||
verses?: Record<string, unknown>[];
|
||||
}>(path, {
|
||||
tags: [`verses-${book}`],
|
||||
});
|
||||
|
||||
return entries;
|
||||
if (!res || !res.verses) return [];
|
||||
return res.verses.map(mapVerseIndex);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -147,27 +253,41 @@ export async function getChapterVerses(
|
||||
page: number = 1,
|
||||
limit: number = DEFAULT_PAGE_SIZE,
|
||||
): Promise<PaginatedVerses> {
|
||||
const skip = (page - 1) * limit;
|
||||
const path = versePath(book, division, chapter);
|
||||
|
||||
const where = { book, division, chapter };
|
||||
|
||||
const [verses, total] = await Promise.all([
|
||||
prisma.verse.findMany({
|
||||
where,
|
||||
orderBy: { verse: "asc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.verse.count({ where }),
|
||||
]);
|
||||
const raw = await bff<{
|
||||
verses: Record<string, unknown>[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>(`${path}/verses`, {
|
||||
params: {
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
},
|
||||
tags: [`verses-${book}`],
|
||||
});
|
||||
|
||||
if (!raw) {
|
||||
return {
|
||||
verses: verses as unknown as VerseRecord[],
|
||||
total,
|
||||
verses: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasMore: skip + verses.length < total,
|
||||
totalPages: 0,
|
||||
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,
|
||||
verse: number,
|
||||
): Promise<VerseRecord | null> {
|
||||
const result = await prisma.verse.findFirst({
|
||||
where: { book, division, chapter, verse },
|
||||
const path = versePath(book, division, chapter);
|
||||
const raw = await bff<Record<string, unknown>>(`${path}/${verse}`, {
|
||||
tags: [`verses-${book}`],
|
||||
});
|
||||
|
||||
return result as unknown as VerseRecord | null;
|
||||
return raw ? mapVerse(raw) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,109 +319,76 @@ export async function getSingleVerse(
|
||||
* Get all prose chapters/segments for a given book and volume,
|
||||
* optionally filtered by type ("chapter" | "intro-segment").
|
||||
* 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(
|
||||
book: string,
|
||||
volume: number,
|
||||
type?: string,
|
||||
): Promise<ProseChapterRecord[]> {
|
||||
const where: Record<string, unknown> = { book, volume };
|
||||
if (type) where.type = type;
|
||||
// KB has real volumes (1, 2, 3, …); other prose books use volume=0 as sentinel
|
||||
const path =
|
||||
book === "kb" && volume > 0
|
||||
? `/books/kb/${volume}`
|
||||
: `/books/${book}/chapters`;
|
||||
|
||||
const results = await prisma.proseChapter.findMany({
|
||||
where,
|
||||
orderBy: { order: "asc" },
|
||||
const params: Record<string, string> = {};
|
||||
if (type) params.type = type;
|
||||
|
||||
const chapters = await bffList<ProseChapterRecord>(path, {
|
||||
params: Object.keys(params).length > 0 ? params : undefined,
|
||||
tags: [`book-${book}`],
|
||||
});
|
||||
|
||||
return results.map(normalizeProse);
|
||||
return chapters.map((ch) => ({
|
||||
...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.
|
||||
*
|
||||
* For KB, uses /api/v1/books/kb/{volume}/{slug}.
|
||||
* For other books, uses /api/v1/books/{code}/chapters/{slug}.
|
||||
*/
|
||||
export async function getProseChapter(
|
||||
book: string,
|
||||
volume: number,
|
||||
slug: string,
|
||||
): Promise<ProseChapterRecord | null> {
|
||||
const result = await prisma.proseChapter.findUnique({
|
||||
where: {
|
||||
book_volume_slug: { book, volume, slug },
|
||||
},
|
||||
const path =
|
||||
book === "kb" && volume > 0
|
||||
? `/books/kb/${volume}/${slug}`
|
||||
: `/books/${book}/chapters/${slug}`;
|
||||
|
||||
const chapter = await bff<ProseChapterRecord>(path, {
|
||||
tags: [`book-${book}`],
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (!chapter) return null;
|
||||
return {
|
||||
id: doc.id,
|
||||
book: doc.book,
|
||||
volume: doc.volume,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
order: doc.order,
|
||||
paragraphs,
|
||||
...chapter,
|
||||
paragraphs: sanitizeParagraphs(chapter.paragraphs),
|
||||
id: chapter.id ?? "",
|
||||
book: chapter.book ?? book,
|
||||
volume: chapter.volume ?? volume,
|
||||
chapter: chapter.chapter ?? null,
|
||||
title: chapter.title ?? "",
|
||||
type: chapter.type ?? "chapter",
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -310,9 +397,17 @@ function sanitizeHtml(html: string): string {
|
||||
* Get all glossary entries, ordered alphabetically by term.
|
||||
*/
|
||||
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
|
||||
const entries = await prisma.glossaryEntry.findMany({
|
||||
orderBy: { term: "asc" },
|
||||
});
|
||||
const raw = await bffList<{
|
||||
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
@@ -1,280 +1,57 @@
|
||||
/**
|
||||
* Data access layer for Beyond Birth and Death (BBD).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "bbd";
|
||||
|
||||
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> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as BbdMeta;
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getBbdIntroSegments(): Promise<BbdIntroSegment[]> {
|
||||
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),
|
||||
}));
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getBbdIntroSegment(
|
||||
slug: string,
|
||||
): Promise<BbdIntroSegment | 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),
|
||||
};
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–5).
|
||||
*/
|
||||
export async function getBbdChapter(
|
||||
chapterNum: number,
|
||||
): Promise<BbdChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 5 || !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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "5" → chapter
|
||||
*/
|
||||
export async function getBbdContent(
|
||||
slug: string,
|
||||
): Promise<BbdChapter | BbdIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getBbdIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
||||
return getBbdChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–5
|
||||
*/
|
||||
export async function getBbdSidebarItems(): Promise<BbdSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
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;
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+161
@@ -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;
|
||||
}
|
||||
@@ -1,299 +1,29 @@
|
||||
/**
|
||||
* Data access layer for Bhagavāner Kathā (BK).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* BK is a simple prose book with no volume structure and no intro segments —
|
||||
* just 2 chapters directly. 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/bk/.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
import {
|
||||
getBookMeta,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
const CODE = "bk";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type BkMeta = BookMeta;
|
||||
export type BkChapterEntry = { number: number; title: string };
|
||||
export type BkChapter = Chapter;
|
||||
export type BkSidebarItem = SidebarItem;
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
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 getBkMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getBkChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getBkContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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;
|
||||
export async function getBkSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+398
@@ -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;
|
||||
}
|
||||
@@ -1,357 +1,70 @@
|
||||
/**
|
||||
* Data access layer for Śrī Brahma-saṁhitā (BS).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* BS has BOTH verses (in the `verses` collection)
|
||||
* AND prose intro segments (in the `prose_chapters` collection).
|
||||
* Delegates to the generic book data layer (BFF API) for prose content,
|
||||
* and to lib/api.ts for verse lookups.
|
||||
*
|
||||
* Verses were seeded with:
|
||||
* - book: "bs", division: null
|
||||
* - 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"
|
||||
* BS has 62 texts (verses) plus intro segments.
|
||||
* Verses are stored with: book "bs", division null, chapter 5, verse = text number.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
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";
|
||||
const NO_VOLUME = 0;
|
||||
export type BsMeta = BookMeta;
|
||||
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> = {
|
||||
1: "Text 1",
|
||||
2: "Text 2",
|
||||
3: "Text 3",
|
||||
4: "Text 4",
|
||||
5: "Text 5",
|
||||
6: "Text 6",
|
||||
7: "Text 7",
|
||||
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;
|
||||
export async function getBsMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
export async function getBsIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
export async function getBsIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const num = Number(slug);
|
||||
if (!Number.isInteger(num) || num < 1 || num > 62) return null;
|
||||
const textNum = 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),
|
||||
};
|
||||
const num = parseInt(slug, 10);
|
||||
if (isNaN(num)) return null;
|
||||
return getSingleVerse(CODE, null, 5, num);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-introduction" → intro segment
|
||||
* "intro-foreword" → intro segment
|
||||
* "1" … "62" → verse (text)
|
||||
*/
|
||||
export async function getBsContent(
|
||||
slug: string,
|
||||
): Promise<BsVerse | BsIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getBsIntroSegment(slug);
|
||||
): Promise<BsVerse | BsIntroSegment | BsChapter | null> {
|
||||
// Try as verse first (numeric slug)
|
||||
if (/^\d+$/.test(slug)) {
|
||||
const verse = await getBsVerse(slug);
|
||||
if (verse) return verse;
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 62) {
|
||||
return getBsVerse(String(num));
|
||||
}
|
||||
|
||||
return null;
|
||||
// Fall back to prose content (intro segments, etc.)
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
export async function getBsSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+22
-292
@@ -1,299 +1,29 @@
|
||||
/**
|
||||
* Data access layer for Śrī Caitanya-bhāgavata Commentary (CBHC).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* CBHC is a simple prose book with no volume structure and no intro segments —
|
||||
* just 4 chapters directly. 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/cbhc/.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
import {
|
||||
getBookMeta,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
const CODE = "cbhc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type CbhcMeta = BookMeta;
|
||||
export type CbhcChapterEntry = { number: number; title: string };
|
||||
export type CbhcChapter = Chapter;
|
||||
export type CbhcSidebarItem = SidebarItem;
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
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 getCbhcMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getCbhcChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getCbhcContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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;
|
||||
export async function getCbhcSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
@@ -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
@@ -2,14 +2,14 @@
|
||||
* Data access layer for Disciples.
|
||||
*
|
||||
* 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").
|
||||
* The letter index aggregates disciples by the first letter of their initiatedName.
|
||||
* Place and year groupings allow browsing by initiation location and year.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bff, bffList } from "@/lib/bff";
|
||||
|
||||
// Types
|
||||
|
||||
@@ -37,26 +37,51 @@ export type YearGroup = {
|
||||
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 ---
|
||||
|
||||
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 ---
|
||||
|
||||
/** Get total number of disciples in the collection. */
|
||||
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. */
|
||||
@@ -64,14 +89,19 @@ export async function getFilteredDiscipleCount(
|
||||
letter: string | null,
|
||||
suffix: SuffixFilter = "all",
|
||||
): Promise<number> {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (letter) {
|
||||
where.initiatedName = { startsWith: letter, mode: "insensitive" };
|
||||
const params: Record<string, string> = {};
|
||||
if (letter) params.letter = letter;
|
||||
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;
|
||||
}
|
||||
return prisma.disciple.count({ where });
|
||||
|
||||
// Fetch filtered list and count client-side
|
||||
// TODO: Consider adding a dedicated BFF count endpoint with filter params for better performance
|
||||
const disciples = await bffList<BffDisciple>("/disciples", { params });
|
||||
return disciples.length;
|
||||
}
|
||||
|
||||
// --- Letter-based queries ---
|
||||
@@ -79,23 +109,16 @@ export async function getFilteredDiscipleCount(
|
||||
/**
|
||||
* Get all disciples whose initiatedName starts with the given letter.
|
||||
*
|
||||
* Uses Prisma's `startsWith` filter with case-insensitive mode.
|
||||
* Results are ordered by initiatedName ascending.
|
||||
* Uses BFF endpoint with letter query param.
|
||||
* Results are ordered by initiatedName ascending (handled by BFF).
|
||||
*/
|
||||
export async function getDisciplesByLetter(
|
||||
letter: string,
|
||||
): Promise<DiscipleSummary[]> {
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
where: {
|
||||
initiatedName: {
|
||||
startsWith: letter,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
orderBy: { initiatedName: "asc" },
|
||||
select: discipleSummarySelect,
|
||||
const disciples = await bffList<BffDisciple>("/disciples", {
|
||||
params: { letter },
|
||||
});
|
||||
return disciples;
|
||||
return disciples.map(mapDisciple);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,16 +130,11 @@ export async function getDisciplesByLetter(
|
||||
export async function getAllDisciples(
|
||||
suffix: SuffixFilter = "all",
|
||||
): Promise<DiscipleSummary[]> {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (suffix !== "all") {
|
||||
where.suffix = suffix;
|
||||
}
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
where,
|
||||
orderBy: { initiatedName: "asc" },
|
||||
select: discipleSummarySelect,
|
||||
});
|
||||
return disciples;
|
||||
const params: Record<string, string> = {};
|
||||
if (suffix !== "all") params.suffix = suffix;
|
||||
|
||||
const disciples = await bffList<BffDisciple>("/disciples", { params });
|
||||
return disciples.map(mapDisciple);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,19 +151,12 @@ export async function getFilteredDisciples(
|
||||
letter: string | null,
|
||||
suffix: SuffixFilter = "all",
|
||||
): Promise<DiscipleSummary[]> {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (letter) {
|
||||
where.initiatedName = { startsWith: letter, mode: "insensitive" };
|
||||
}
|
||||
if (suffix !== "all") {
|
||||
where.suffix = suffix;
|
||||
}
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
where,
|
||||
orderBy: { initiatedName: "asc" },
|
||||
select: discipleSummarySelect,
|
||||
});
|
||||
return disciples;
|
||||
const params: Record<string, string> = {};
|
||||
if (letter) params.letter = letter;
|
||||
if (suffix !== "all") params.suffix = suffix;
|
||||
|
||||
const disciples = await bffList<BffDisciple>("/disciples", { params });
|
||||
return disciples.map(mapDisciple);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,42 +167,22 @@ export async function getFilteredDisciples(
|
||||
export async function getDiscipleSuffixCounts(): Promise<
|
||||
{ suffix: string; count: number }[]
|
||||
> {
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
select: { suffix: true },
|
||||
});
|
||||
|
||||
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 }))
|
||||
const suffixes = await bffList<BffSuffixCount>("/disciples/suffixes");
|
||||
return suffixes
|
||||
.map((s) => ({ suffix: s._id, count: s.count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get counts of disciples grouped by the first letter of their initiatedName.
|
||||
*
|
||||
* Fetches all initiatedName values and aggregates first letters in JavaScript
|
||||
* (Prisma/MongoDB doesn't support substring aggregations easily).
|
||||
* Uses the BFF /disciples/letters endpoint which returns pre-aggregated data.
|
||||
* Returns results sorted alphabetically by letter.
|
||||
*/
|
||||
export async function getDiscipleLetterCounts(): Promise<LetterCount[]> {
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
select: { initiatedName: true },
|
||||
});
|
||||
|
||||
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 }))
|
||||
const letters = await bffList<BffLetterCount>("/disciples/letters");
|
||||
return letters
|
||||
.map((l) => ({ letter: l._id, count: l.count }))
|
||||
.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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* TODO: Add a BFF endpoint (GET /api/v1/disciples/places) for better performance.
|
||||
*/
|
||||
export async function getDisciplePlaces(): Promise<PlaceGroup[]> {
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
where: { initiationPlace: { not: null } },
|
||||
select: { initiationPlace: true },
|
||||
});
|
||||
const disciples = await bffList<BffDisciple>("/disciples");
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const d of disciples) {
|
||||
if (d.initiationPlace) {
|
||||
counts[d.initiationPlace] = (counts[d.initiationPlace] || 0) + 1;
|
||||
if (d.initiation_place) {
|
||||
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.
|
||||
*
|
||||
* No dedicated BFF endpoint — fetches all disciples and filters in JS.
|
||||
* Results are ordered by initiatedName ascending.
|
||||
*
|
||||
* TODO: Add BFF query param support (GET /api/v1/disciples?place=...) for better performance.
|
||||
*/
|
||||
export async function getDisciplesByPlace(
|
||||
place: string,
|
||||
): Promise<DiscipleSummary[]> {
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
where: { initiationPlace: place },
|
||||
orderBy: { initiatedName: "asc" },
|
||||
select: discipleSummarySelect,
|
||||
});
|
||||
return disciples;
|
||||
const disciples = await bffList<BffDisciple>("/disciples");
|
||||
|
||||
return disciples
|
||||
.filter((d) => d.initiation_place === place)
|
||||
.map(mapDisciple)
|
||||
.sort((a, b) => a.initiatedName.localeCompare(b.initiatedName));
|
||||
}
|
||||
|
||||
// --- Year-based queries ---
|
||||
@@ -242,23 +235,23 @@ export async function getDisciplesByPlace(
|
||||
/**
|
||||
* Get unique initiation years with disciple counts.
|
||||
*
|
||||
* Fetches all non-null initiationDate values and aggregates years in JavaScript
|
||||
* (Prisma/MongoDB doesn't support date-part aggregations easily).
|
||||
* No dedicated BFF endpoint — fetches all disciples and aggregates years in JS.
|
||||
* 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[]> {
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
where: { initiationDate: { not: null } },
|
||||
select: { initiationDate: true },
|
||||
});
|
||||
const disciples = await bffList<BffDisciple>("/disciples");
|
||||
|
||||
const counts: Record<number, number> = {};
|
||||
for (const d of disciples) {
|
||||
if (d.initiationDate) {
|
||||
const year = d.initiationDate.getFullYear();
|
||||
if (d.initiation_date) {
|
||||
const year = new Date(d.initiation_date).getFullYear();
|
||||
if (!isNaN(year)) {
|
||||
counts[year] = (counts[year] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(counts)
|
||||
.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.
|
||||
*
|
||||
* Uses a date range query (gte: Jan 1 of year, lt: Jan 1 of year+1)
|
||||
* to match all initiationDate values within the calendar year.
|
||||
* No dedicated BFF endpoint — fetches all disciples and filters in JS.
|
||||
* Results are ordered by initiatedName ascending.
|
||||
*
|
||||
* TODO: Add BFF query param support (GET /api/v1/disciples?year=...) for better performance.
|
||||
*/
|
||||
export async function getDisciplesByYear(
|
||||
year: number,
|
||||
): Promise<DiscipleSummary[]> {
|
||||
const startOfYear = new Date(year, 0, 1);
|
||||
const startOfNextYear = new Date(year + 1, 0, 1);
|
||||
const disciples = await bffList<BffDisciple>("/disciples");
|
||||
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
where: {
|
||||
initiationDate: {
|
||||
gte: startOfYear,
|
||||
lt: startOfNextYear,
|
||||
},
|
||||
},
|
||||
orderBy: { initiatedName: "asc" },
|
||||
select: discipleSummarySelect,
|
||||
});
|
||||
return disciples;
|
||||
return disciples
|
||||
.filter((d) => {
|
||||
if (!d.initiation_date) return false;
|
||||
const dYear = new Date(d.initiation_date).getFullYear();
|
||||
return dYear === year;
|
||||
})
|
||||
.map(mapDisciple)
|
||||
.sort((a, b) => a.initiatedName.localeCompare(b.initiatedName));
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
@@ -296,22 +286,14 @@ export async function getDisciplesByYear(
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function searchDisciples(
|
||||
query: string,
|
||||
): Promise<DiscipleSummary[]> {
|
||||
const disciples = await prisma.disciple.findMany({
|
||||
where: {
|
||||
initiatedName: {
|
||||
contains: query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
orderBy: { initiatedName: "asc" },
|
||||
take: 100,
|
||||
select: discipleSummarySelect,
|
||||
const disciples = await bffList<BffDisciple>("/disciples/search", {
|
||||
params: { q: query },
|
||||
});
|
||||
return disciples;
|
||||
return disciples.map(mapDisciple);
|
||||
}
|
||||
|
||||
@@ -1,325 +1,47 @@
|
||||
/**
|
||||
* Data access layer for Dialectic Spiritualism (DS).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
getNavigableItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "ds";
|
||||
|
||||
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. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getDsMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getDsIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getDsIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
}));
|
||||
export async function getDsChapter(slug: string) {
|
||||
return getChapter(CODE, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
export async function getDsContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 async function getDsSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
export { getNavigableItems };
|
||||
|
||||
@@ -1,282 +1,57 @@
|
||||
/**
|
||||
* Data access layer for Easy Journey to Other Planets (EJ).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "ej";
|
||||
|
||||
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> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as EjMeta;
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getEjIntroSegments(): Promise<EjIntroSegment[]> {
|
||||
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),
|
||||
}));
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getEjIntroSegment(
|
||||
slug: string,
|
||||
): Promise<EjIntroSegment | 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),
|
||||
};
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–2).
|
||||
*/
|
||||
export async function getEjChapter(
|
||||
chapterNum: number,
|
||||
): Promise<EjChapter | 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 null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "intro-preface" → intro segment
|
||||
* "1", "2" → chapter
|
||||
*/
|
||||
export async function getEjContent(
|
||||
slug: string,
|
||||
): Promise<EjChapter | EjIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getEjIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
|
||||
return getEjChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → preface → chapters 1–2
|
||||
*/
|
||||
export async function getEjSidebarItems(): Promise<EjSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
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;
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
@@ -1,280 +1,57 @@
|
||||
/**
|
||||
* Data access layer for Elevation to Kṛṣṇa Consciousness (EK).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "ek";
|
||||
|
||||
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> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as EkMeta;
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getEkIntroSegments(): Promise<EkIntroSegment[]> {
|
||||
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),
|
||||
}));
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getEkIntroSegment(
|
||||
slug: string,
|
||||
): Promise<EkIntroSegment | 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),
|
||||
};
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–6).
|
||||
*/
|
||||
export async function getEkChapter(
|
||||
chapterNum: number,
|
||||
): Promise<EkChapter | 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 null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "6" → chapter
|
||||
*/
|
||||
export async function getEkContent(
|
||||
slug: string,
|
||||
): Promise<EkChapter | EkIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getEkIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
|
||||
return getEkChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–6
|
||||
*/
|
||||
export async function getEkSidebarItems(): Promise<EkSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
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;
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
@@ -1,443 +1,39 @@
|
||||
/**
|
||||
* Data access layer for Gītār Gān (GG).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* GG is a prose/poetry 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/gg/.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
const CODE = "gg";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type GgMeta = BookMeta;
|
||||
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. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
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 getGgMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getGgIntroSegments() {
|
||||
return getIntroSegments(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;
|
||||
}
|
||||
export async function getGgIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
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));
|
||||
export async function getGgChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): GgIntroSegment | null {
|
||||
const filePath = path.join(GG_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<GgIntroSegment>(filePath);
|
||||
export async function getGgContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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;
|
||||
export async function getGgSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+32
-269
@@ -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.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "hkh";
|
||||
|
||||
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. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getHkhMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getHkhIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getHkhIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
}));
|
||||
export async function getHkhChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
export async function getHkhContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
export async function getHkhSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+41
-313
@@ -1,225 +1,47 @@
|
||||
/**
|
||||
* Data access layer for Śrī Īśopaniṣad (ISO).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* ISO is unique — it has BOTH verses (in the `verses` collection)
|
||||
* 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"
|
||||
* Delegates to the generic book data layer (BFF API) for prose intro segments,
|
||||
* and to lib/api.ts for verse (mantra) lookups.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
import { getSingleVerse, type VerseRecord } from "@/lib/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "iso";
|
||||
|
||||
const BOOK = "iso";
|
||||
const NO_VOLUME = 0;
|
||||
export type IsoMeta = BookMeta;
|
||||
export type IsoIntroSegment = IntroSegment;
|
||||
export type IsoChapter = Chapter;
|
||||
export type IsoSidebarItem = SidebarItem;
|
||||
export type IsoVerse = VerseRecord;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mantra title lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 getIsoMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getIsoIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
|
||||
function normalizePurport(purport: string[] | JsonValue): string[] {
|
||||
return normalizeParagraphs(purport);
|
||||
export async function getIsoIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
*
|
||||
* - "invocation" → chapter=0, verse=1
|
||||
* - "1"-"18" → chapter=1–18, verse=1
|
||||
* ISO verses are stored with: book "iso", no division,
|
||||
* chapter = mantra number (0 for invocation), verse = 1.
|
||||
*/
|
||||
export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
|
||||
let mantraNum: number;
|
||||
@@ -227,119 +49,25 @@ export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
|
||||
if (slug === "invocation") {
|
||||
mantraNum = 0;
|
||||
} else {
|
||||
const num = Number(slug);
|
||||
if (!Number.isInteger(num) || num < 1 || num > 18) return null;
|
||||
const num = parseInt(slug, 10);
|
||||
if (isNaN(num) || num < 1 || num > 18) return null;
|
||||
mantraNum = num;
|
||||
}
|
||||
|
||||
const doc = await prisma.verse.findFirst({
|
||||
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),
|
||||
};
|
||||
return getSingleVerse(CODE, null, mantraNum, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(
|
||||
slug: string,
|
||||
): Promise<IsoVerse | IsoIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getIsoIntroSegment(slug);
|
||||
): Promise<IsoVerse | IsoIntroSegment | IsoChapter | null> {
|
||||
// Try as verse/mantra first (numeric slug or "invocation")
|
||||
if (slug === "invocation" || /^\d+$/.test(slug)) {
|
||||
const verse = await getIsoVerse(slug);
|
||||
if (verse) return verse;
|
||||
}
|
||||
|
||||
if (slug === "invocation") {
|
||||
return getIsoVerse("invocation");
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
|
||||
return getIsoVerse(String(num));
|
||||
}
|
||||
|
||||
return null;
|
||||
// Fall back to prose content (intro segments, etc.)
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
export async function getIsoSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+82
-35
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Data access layer for Journals.
|
||||
*
|
||||
* Uses dedicated Prisma models:
|
||||
* - JournalMeta (collection: journal_meta)
|
||||
* - JournalChapter (collection: journal_chapters)
|
||||
* Uses the BFF API instead of direct Prisma queries.
|
||||
* Falls back to local JSON files when BFF is unavailable.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import { bff, bffList } from "@/lib/bff";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
@@ -65,6 +63,31 @@ export type JournalSidebarItem = {
|
||||
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
|
||||
function sanitizeHtml(html: string): string {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
@@ -80,7 +103,7 @@ function sanitizeHtml(html: string): string {
|
||||
return clean;
|
||||
}
|
||||
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
function normalizeParagraphs(paragraphs: string[] | unknown): string[] {
|
||||
let result: string[];
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
@@ -97,6 +120,51 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
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
|
||||
function findLocalDirForBook(bookCode: string): string | null {
|
||||
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
|
||||
@@ -160,21 +228,9 @@ function getLocalChapterBySlug(
|
||||
// Public API
|
||||
export async function getAllJournals(): Promise<JournalSummary[]> {
|
||||
try {
|
||||
const results = await prisma.journalMeta.findMany({
|
||||
orderBy: { book: "asc" },
|
||||
});
|
||||
const results = await bffList<BffJournalMeta>("/journals");
|
||||
if (results.length > 0) {
|
||||
return results.map((row) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
return results.map(mapBffMetaToSummary);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
@@ -194,10 +250,8 @@ export async function getJournalMeta(
|
||||
bookCode: string,
|
||||
): Promise<JournalMetaData | null> {
|
||||
try {
|
||||
const row = await prisma.journalMeta.findUnique({
|
||||
where: { book: bookCode },
|
||||
});
|
||||
if (row) return row.data as unknown as JournalMetaData;
|
||||
const raw = await bff<BffJournalMeta>(`/journals/${bookCode}`);
|
||||
if (raw) return mapBffMetaToJournalMetaData(raw);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
@@ -210,17 +264,10 @@ export async function getJournalChapterBySlug(
|
||||
): Promise<JournalChapterData | null> {
|
||||
if (!slug) return null;
|
||||
try {
|
||||
const doc = await prisma.journalChapter.findUnique({
|
||||
where: { book_slug: { book: bookCode, slug } },
|
||||
});
|
||||
if (doc)
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
const raw = await bff<BffJournalChapter>(
|
||||
`/journals/${bookCode}/chapters/${slug}`,
|
||||
);
|
||||
if (raw) return mapBffChapter(raw);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
+31
-431
@@ -1,445 +1,45 @@
|
||||
/**
|
||||
* Data access layer for Life Comes from Life (LCL).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "lcl";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
export type LclMeta = BookMeta;
|
||||
export type LclChapterEntry = { number: number; title: string };
|
||||
export type LclIntroSegment = IntroSegment;
|
||||
export type LclChapter = Chapter;
|
||||
export type LclSidebarItem = SidebarItem;
|
||||
|
||||
const BOOK = "lcl";
|
||||
|
||||
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 getLclMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getLclIntroSegments() {
|
||||
return getIntroSegments(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;
|
||||
}
|
||||
export async function getLclIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
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));
|
||||
export async function getLclChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): LclIntroSegment | null {
|
||||
const filePath = path.join(LCL_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<LclIntroSegment>(filePath);
|
||||
export async function getLclContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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;
|
||||
export async function getLclSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+135
-121
@@ -2,7 +2,7 @@
|
||||
* Data access layer for Letters.
|
||||
*
|
||||
* 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"]).
|
||||
* The recipient list aggregates individual names across all letters,
|
||||
@@ -10,9 +10,12 @@
|
||||
* ANY recipient (individual or group).
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bff, bffList } from "@/lib/bff";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LetterSummary = {
|
||||
slug: string;
|
||||
recipients: string[];
|
||||
@@ -52,7 +55,43 @@ export type LocationGroup = {
|
||||
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 {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
@@ -65,23 +104,60 @@ function sanitizeHtml(html: string): string {
|
||||
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[]> {
|
||||
const letters = await prisma.letter.findMany({
|
||||
where: { year: { not: null } },
|
||||
select: { year: true },
|
||||
const raw = await bffList<BffAggregation>("/letters/years", {
|
||||
tags: ["letters"],
|
||||
});
|
||||
|
||||
const counts: Record<number, number> = {};
|
||||
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 }))
|
||||
return raw
|
||||
.map(mapYearGroup)
|
||||
.filter((g) => g.year >= 1940 && g.year <= 1980)
|
||||
.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.
|
||||
*
|
||||
* Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]).
|
||||
* This function aggregates across all letters so that "Mukunda" appears
|
||||
* once with count = total letters where Mukunda is ANY recipient.
|
||||
* The BFF aggregation endpoint already aggregates across all letters so
|
||||
* that "Mukunda" appears once with count = total letters where Mukunda
|
||||
* is ANY recipient.
|
||||
*/
|
||||
export async function getLetterRecipients(): Promise<RecipientGroup[]> {
|
||||
const letters = await prisma.letter.findMany({
|
||||
select: { recipients: true },
|
||||
const raw = await bffList<BffAggregation>("/letters/recipients", {
|
||||
tags: ["letters"],
|
||||
});
|
||||
|
||||
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);
|
||||
return raw.map(mapRecipientGroup).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export async function getLetterLocations(): Promise<LocationGroup[]> {
|
||||
const letters = await prisma.letter.findMany({
|
||||
where: { location: { not: null } },
|
||||
select: { location: true },
|
||||
const raw = await bffList<BffAggregation>("/letters/locations", {
|
||||
tags: ["letters"],
|
||||
});
|
||||
|
||||
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 }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
return raw.map(mapLocationGroup).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
// --- Letter list queries ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// Letter list queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
|
||||
const letters = await prisma.letter.findMany({
|
||||
where: { year },
|
||||
orderBy: [{ month: "asc" }, { day: "asc" }],
|
||||
select: {
|
||||
slug: true,
|
||||
recipients: true,
|
||||
recipientDisplay: true,
|
||||
date: true,
|
||||
year: true,
|
||||
month: true,
|
||||
location: true,
|
||||
},
|
||||
const raw = await bffList<BffLetterSummary>(`/letters/year/${year}`, {
|
||||
tags: ["letters"],
|
||||
});
|
||||
return letters;
|
||||
return raw.map(mapLetterSummary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* - "Mukunda" (individual)
|
||||
* - "Mukunda, Janaki" (group containing Mukunda)
|
||||
@@ -159,63 +207,38 @@ export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
|
||||
export async function getLettersByRecipient(
|
||||
recipient: string,
|
||||
): Promise<LetterSummary[]> {
|
||||
const letters = await prisma.letter.findMany({
|
||||
where: { recipients: { has: recipient } },
|
||||
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
||||
select: {
|
||||
slug: true,
|
||||
recipients: true,
|
||||
recipientDisplay: true,
|
||||
date: true,
|
||||
year: true,
|
||||
month: true,
|
||||
location: true,
|
||||
},
|
||||
const encoded = encodeURIComponent(recipient);
|
||||
const raw = await bffList<BffLetterSummary>(`/letters/recipient/${encoded}`, {
|
||||
tags: ["letters"],
|
||||
});
|
||||
return letters;
|
||||
return raw.map(mapLetterSummary);
|
||||
}
|
||||
|
||||
export async function getLettersByLocation(
|
||||
location: string,
|
||||
): Promise<LetterSummary[]> {
|
||||
const letters = await prisma.letter.findMany({
|
||||
where: { location },
|
||||
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
||||
select: {
|
||||
slug: true,
|
||||
recipients: true,
|
||||
recipientDisplay: true,
|
||||
date: true,
|
||||
year: true,
|
||||
month: true,
|
||||
location: true,
|
||||
},
|
||||
const encoded = encodeURIComponent(location);
|
||||
const raw = await bffList<BffLetterSummary>(`/letters/location/${encoded}`, {
|
||||
tags: ["letters"],
|
||||
});
|
||||
return letters;
|
||||
return raw.map(mapLetterSummary);
|
||||
}
|
||||
|
||||
// --- Single letter ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single letter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
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),
|
||||
};
|
||||
return mapLetterFull(letter);
|
||||
}
|
||||
|
||||
// --- Navigation helpers ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getAdjacentLetters(
|
||||
slug: string,
|
||||
@@ -223,27 +246,18 @@ export async function getAdjacentLetters(
|
||||
): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> {
|
||||
if (!year) return { prev: null, next: null };
|
||||
|
||||
const yearLetters = await prisma.letter.findMany({
|
||||
where: { year },
|
||||
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
|
||||
select: {
|
||||
slug: true,
|
||||
recipients: true,
|
||||
recipientDisplay: true,
|
||||
date: true,
|
||||
year: true,
|
||||
month: true,
|
||||
location: true,
|
||||
},
|
||||
});
|
||||
const letters = await getLettersByYear(year);
|
||||
const idx = letters.findIndex((l) => l.slug === slug);
|
||||
|
||||
const idx = yearLetters.findIndex((l) => l.slug === slug);
|
||||
return {
|
||||
prev: idx > 0 ? yearLetters[idx - 1] : null,
|
||||
next: idx < yearLetters.length - 1 ? yearLetters[idx + 1] : null,
|
||||
prev: idx > 0 ? letters[idx - 1] : null,
|
||||
next: idx < letters.length - 1 ? letters[idx + 1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
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
@@ -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.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "lob";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
export type LobMeta = BookMeta;
|
||||
export type LobChapterEntry = { number: number; title: string };
|
||||
export type LobIntroSegment = IntroSegment;
|
||||
export type LobChapter = Chapter;
|
||||
export type LobSidebarItem = SidebarItem;
|
||||
|
||||
const BOOK = "lob";
|
||||
|
||||
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 getLobMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getLobIntroSegments() {
|
||||
return getIntroSegments(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;
|
||||
}
|
||||
export async function getLobIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
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));
|
||||
export async function getLobChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): LobIntroSegment | null {
|
||||
const filePath = path.join(LOB_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<LobIntroSegment>(filePath);
|
||||
export async function getLobContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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;
|
||||
export async function getLobSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+92
-38
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Data access layer for Magazines (Back to Godhead, etc.).
|
||||
*
|
||||
* Uses dedicated Prisma models:
|
||||
* - MagazineMeta (collection: magazine_meta)
|
||||
* - MagazineIssue (collection: magazine_issues)
|
||||
* Uses the BFF API instead of direct Prisma queries.
|
||||
* Falls back to local JSON files when BFF is unavailable.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import { bff, bffList } from "@/lib/bff";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
@@ -57,6 +55,35 @@ export type MagazineSidebarItem = {
|
||||
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 {
|
||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||
@@ -71,7 +98,7 @@ function sanitizeHtml(html: string): string {
|
||||
return clean;
|
||||
}
|
||||
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
function normalizeParagraphs(paragraphs: string[] | unknown): string[] {
|
||||
let result: string[];
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
@@ -88,6 +115,57 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
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 {
|
||||
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
|
||||
const entries = fs.readdirSync(LOCAL_JSON_DIR);
|
||||
@@ -152,22 +230,9 @@ function getLocalIssue(
|
||||
|
||||
export async function getAllMagazines(): Promise<MagazineSummary[]> {
|
||||
try {
|
||||
const results = await prisma.magazineMeta.findMany({
|
||||
orderBy: { book: "asc" },
|
||||
});
|
||||
const results = await bffList<BffMagazineMeta>("/magazines");
|
||||
if (results.length > 0) {
|
||||
return results.map((row) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
return results.map(mapBffMetaToSummary);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
@@ -188,10 +253,8 @@ export async function getMagazineMeta(
|
||||
bookCode: string,
|
||||
): Promise<MagazineMetaData | null> {
|
||||
try {
|
||||
const row = await prisma.magazineMeta.findUnique({
|
||||
where: { book: bookCode },
|
||||
});
|
||||
if (row) return row.data as unknown as MagazineMetaData;
|
||||
const raw = await bff<BffMagazineMeta>(`/magazines/${bookCode}`);
|
||||
if (raw) return mapBffMetaToMagazineMetaData(raw);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
@@ -204,19 +267,10 @@ export async function getMagazineIssue(
|
||||
): Promise<MagazineIssueData | null> {
|
||||
if (!slug) return null;
|
||||
try {
|
||||
const doc = await prisma.magazineIssue.findUnique({
|
||||
where: { book_slug: { book: bookCode, slug } },
|
||||
});
|
||||
if (doc) {
|
||||
return {
|
||||
book: doc.book,
|
||||
volume: doc.volume,
|
||||
part: doc.part,
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
}
|
||||
const raw = await bff<BffMagazineIssue>(
|
||||
`/magazines/${bookCode}/issues/${slug}`,
|
||||
);
|
||||
if (raw) return mapBffIssue(raw);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
@@ -1,280 +1,57 @@
|
||||
/**
|
||||
* Data access layer for Kṛṣṇa Consciousness: The Matchless Gift (MG).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "mg";
|
||||
|
||||
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> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as MgMeta;
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getMgIntroSegments(): Promise<MgIntroSegment[]> {
|
||||
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),
|
||||
}));
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getMgIntroSegment(
|
||||
slug: string,
|
||||
): Promise<MgIntroSegment | 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),
|
||||
};
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–7).
|
||||
*/
|
||||
export async function getMgChapter(
|
||||
chapterNum: number,
|
||||
): Promise<MgChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 7 || !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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "7" → chapter
|
||||
*/
|
||||
export async function getMgContent(
|
||||
slug: string,
|
||||
): Promise<MgChapter | MgIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getMgIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 7) {
|
||||
return getMgChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–7
|
||||
*/
|
||||
export async function getMgSidebarItems(): Promise<MgSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
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;
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+31
-416
@@ -1,430 +1,45 @@
|
||||
/**
|
||||
* Data access layer for Message of Godhead (MOG).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "mog";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
export type MogMeta = BookMeta;
|
||||
export type MogChapterEntry = { number: number; title: string };
|
||||
export type MogIntroSegment = IntroSegment;
|
||||
export type MogChapter = Chapter;
|
||||
export type MogSidebarItem = SidebarItem;
|
||||
|
||||
const BOOK = "mog";
|
||||
|
||||
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 getMogMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getMogIntroSegments() {
|
||||
return getIntroSegments(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;
|
||||
}
|
||||
export async function getMogIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
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));
|
||||
export async function getMogChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
function getLocalIntroSegment(rawSlug: string): MogIntroSegment | null {
|
||||
const filePath = path.join(MOG_JSON_DIR, "intro", `${rawSlug}.json`);
|
||||
return readLocalJson<MogIntroSegment>(filePath);
|
||||
export async function getMogContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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;
|
||||
export async function getMogSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+34
-317
@@ -1,332 +1,49 @@
|
||||
/**
|
||||
* Data access layer for The Nectar of Devotion (NOD).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "nod";
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
export type NodMeta = BookMeta;
|
||||
export type NodChapterEntry = { number: number; title: string };
|
||||
export type NodIntroSegment = IntroSegment;
|
||||
export type NodChapter = Chapter;
|
||||
export type NodSidebarItem = SidebarItem;
|
||||
|
||||
const BOOK = "nod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getNodMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getNodIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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 getNodIntroSegment(slug: string) {
|
||||
return getIntroSegment(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 getNodChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
// NodConcludingWords is just a chapter with slug "concluding-words"
|
||||
export async function getNodConcludingWords() {
|
||||
return getChapter(CODE, "concluding-words");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
};
|
||||
export async function getNodContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
export async function getNodSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+45
-278
@@ -1,322 +1,89 @@
|
||||
/**
|
||||
* Data access layer for The Nectar of Instruction (NoI).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* NoI has 11 texts (verses) plus intro segments (front-pages, preface, appendixes).
|
||||
* Delegates to the generic book data layer (BFF API) for prose intro segments,
|
||||
* and to lib/api.ts for verse (text) lookups.
|
||||
*
|
||||
* Verses were seeded with:
|
||||
* - book: "noi", division: null
|
||||
* - 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"
|
||||
* NoI has 11 texts (verses) plus intro segments.
|
||||
* Verses are stored with: book "noi", division null, chapter = text number, verse = 1.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
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";
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text title lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
export type NoiMeta = BookMeta;
|
||||
export type NoiIntroSegment = IntroSegment;
|
||||
export type NoiChapter = Chapter;
|
||||
export type NoiSidebarItem = SidebarItem;
|
||||
export type NoiVerse = VerseRecord;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Book-level metadata (title, author, texts list, etc.)
|
||||
*/
|
||||
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;
|
||||
export async function getNoiMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments (from prose_chapters collection)
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
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 getNoiIntroSegments() {
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by 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),
|
||||
};
|
||||
export async function getNoiIntroSegment(slug: string) {
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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> {
|
||||
const textNum = Number(slug);
|
||||
if (!Number.isInteger(textNum) || textNum < 1 || textNum > 11) return null;
|
||||
|
||||
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),
|
||||
};
|
||||
const num = parseInt(slug, 10);
|
||||
if (isNaN(num)) return null;
|
||||
return getSingleVerse(CODE, null, num, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(
|
||||
slug: string,
|
||||
): Promise<NoiVerse | NoiIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getNoiIntroSegment(slug);
|
||||
): Promise<NoiVerse | NoiIntroSegment | NoiChapter | null> {
|
||||
// Try as verse first (numeric slug)
|
||||
if (/^\d+$/.test(slug)) {
|
||||
const verse = await getNoiVerse(slug);
|
||||
if (verse) return verse;
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
|
||||
return getNoiVerse(String(num));
|
||||
}
|
||||
|
||||
return null;
|
||||
// Fall back to prose content (intro segments, etc.)
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* 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;
|
||||
export async function getNoiSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+27
-250
@@ -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.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "ochkm";
|
||||
|
||||
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> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as OchkmMeta;
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getOchkmIntroSegments(): Promise<OchkmIntroSegment[]> {
|
||||
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),
|
||||
}));
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getOchkmIntroSegment(
|
||||
slug: string,
|
||||
): Promise<OchkmIntroSegment | 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),
|
||||
};
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–4).
|
||||
*/
|
||||
export async function getOchkmChapter(
|
||||
chapterNum: number,
|
||||
): Promise<OchkmChapter | 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 null;
|
||||
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
slug: doc.slug,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "4" → chapter
|
||||
*/
|
||||
export async function getOchkmContent(
|
||||
slug: string,
|
||||
): Promise<OchkmChapter | OchkmIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getOchkmIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
|
||||
return getOchkmChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–4
|
||||
*/
|
||||
export async function getOchkmSidebarItems(): Promise<OchkmSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
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;
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+87
-55
@@ -1,18 +1,14 @@
|
||||
/**
|
||||
* Data access layer for Other Documents.
|
||||
*
|
||||
* Uses dedicated Prisma models:
|
||||
* - OtherDocumentMeta (collection: other_document_meta)
|
||||
* - OtherDocumentChapter (collection: other_document_chapters)
|
||||
*
|
||||
* Calls the BFF API for all data access (no direct Prisma usage).
|
||||
* 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").
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import { bff, bffList } from "@/lib/bff";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
@@ -72,6 +68,33 @@ export type OtherDocumentSidebarItem = {
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -90,23 +113,52 @@ function sanitizeHtml(html: string): string {
|
||||
return clean;
|
||||
}
|
||||
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mappers (BFF response → frontend types)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 = [];
|
||||
}
|
||||
function mapMetaData(raw: BffDocumentMeta): OtherDocumentMetaData {
|
||||
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,
|
||||
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[]> {
|
||||
try {
|
||||
const results = await prisma.otherDocumentMeta.findMany({
|
||||
orderBy: { book: "asc" },
|
||||
const results = await bffList<BffDocumentMeta>("/other-documents", {
|
||||
tags: ["other-documents"],
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
return results.map((row) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
return results.map(mapSummary);
|
||||
}
|
||||
} catch {
|
||||
// fall through to local JSON
|
||||
@@ -234,12 +276,12 @@ export async function getOtherDocumentMeta(
|
||||
bookCode: string,
|
||||
): Promise<OtherDocumentMetaData | null> {
|
||||
try {
|
||||
const row = await prisma.otherDocumentMeta.findUnique({
|
||||
where: { book: bookCode },
|
||||
const raw = await bff<BffDocumentMeta>(`/other-documents/${bookCode}`, {
|
||||
tags: [`other-doc-${bookCode}`],
|
||||
});
|
||||
|
||||
if (row) {
|
||||
return row.data as unknown as OtherDocumentMetaData;
|
||||
if (raw) {
|
||||
return mapMetaData(raw);
|
||||
}
|
||||
} catch {
|
||||
// fall through to local JSON
|
||||
@@ -258,23 +300,13 @@ export async function getOtherDocumentChapter(
|
||||
if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null;
|
||||
|
||||
try {
|
||||
const doc = await prisma.otherDocumentChapter.findUnique({
|
||||
where: {
|
||||
book_slug: {
|
||||
book: bookCode,
|
||||
slug: String(chapterNum),
|
||||
},
|
||||
},
|
||||
});
|
||||
const raw = await bff<BffChapter>(
|
||||
`/other-documents/${bookCode}/chapters/${chapterNum}`,
|
||||
{ tags: [`other-doc-${bookCode}`] },
|
||||
);
|
||||
|
||||
if (doc) {
|
||||
return {
|
||||
book: doc.book,
|
||||
chapter: doc.chapter,
|
||||
title: doc.title,
|
||||
type: doc.type,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
};
|
||||
if (raw) {
|
||||
return mapChapter(raw);
|
||||
}
|
||||
} catch {
|
||||
// fall through to local JSON
|
||||
|
||||
+26
-249
@@ -1,280 +1,57 @@
|
||||
/**
|
||||
* Data access layer for On the Way to Kṛṣṇa (OWK).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "owk";
|
||||
|
||||
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> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as OwkMeta;
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getOwkIntroSegments(): Promise<OwkIntroSegment[]> {
|
||||
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),
|
||||
}));
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getOwkIntroSegment(
|
||||
slug: string,
|
||||
): Promise<OwkIntroSegment | 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),
|
||||
};
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–5).
|
||||
*/
|
||||
export async function getOwkChapter(
|
||||
chapterNum: number,
|
||||
): Promise<OwkChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 5 || !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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "5" → chapter
|
||||
*/
|
||||
export async function getOwkContent(
|
||||
slug: string,
|
||||
): Promise<OwkChapter | OwkIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getOwkIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
||||
return getOwkChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–5
|
||||
*/
|
||||
export async function getOwkSidebarItems(): Promise<OwkSidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
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;
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
@@ -1,306 +1,29 @@
|
||||
/**
|
||||
* Data access layer for Prayers of King Kulaśekhara (PK).
|
||||
*
|
||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
||||
* PK is a prose book with no volume structure and no intro segments —
|
||||
* 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/pk/.
|
||||
*
|
||||
* Collections used:
|
||||
* - book_meta (via Prisma BookMeta model)
|
||||
* - prose_chapters (via Prisma ProseChapter model)
|
||||
*/
|
||||
import {
|
||||
getBookMeta,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
const CODE = "pk";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type PkMeta = BookMeta;
|
||||
export type PkChapterEntry = { number: number; title: string };
|
||||
export type PkChapter = Chapter;
|
||||
export type PkSidebarItem = SidebarItem;
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
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 getPkMeta() {
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
export async function getPkChapter(chapterNum: number) {
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 getPkContent(slug: string) {
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
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;
|
||||
export async function getPkSidebarItems() {
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
+26
-249
@@ -1,280 +1,57 @@
|
||||
/**
|
||||
* Data access layer for The Perfection of Yoga (POY).
|
||||
*
|
||||
* Reads from MongoDB via Prisma.
|
||||
* 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)
|
||||
* Delegates to the generic book data layer which calls the BFF API.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
type IntroSegment,
|
||||
type Chapter,
|
||||
type SidebarItem,
|
||||
} from "@/lib/books";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CODE = "poy";
|
||||
|
||||
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> {
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
||||
if (!meta) return null;
|
||||
return meta.data as unknown as PoyMeta;
|
||||
return getBookMeta(CODE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intro segments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All intro segments, ordered by their `order` field.
|
||||
*/
|
||||
export async function getPoyIntroSegments(): Promise<PoyIntroSegment[]> {
|
||||
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),
|
||||
}));
|
||||
return getIntroSegments(CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single intro segment by slug.
|
||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
||||
*/
|
||||
export async function getPoyIntroSegment(
|
||||
slug: string,
|
||||
): Promise<PoyIntroSegment | 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),
|
||||
};
|
||||
return getIntroSegment(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A numbered chapter (1–11).
|
||||
*/
|
||||
export async function getPoyChapter(
|
||||
chapterNum: number,
|
||||
): Promise<PoyChapter | null> {
|
||||
if (chapterNum < 1 || chapterNum > 11 || !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),
|
||||
};
|
||||
return getChapter(CODE, String(chapterNum));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified content accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get content by URL slug.
|
||||
*
|
||||
* "intro-front-pages" → intro segment
|
||||
* "1" … "11" → chapter
|
||||
*/
|
||||
export async function getPoyContent(
|
||||
slug: string,
|
||||
): Promise<PoyChapter | PoyIntroSegment | null> {
|
||||
if (slug.startsWith("intro-")) {
|
||||
return getPoyIntroSegment(slug);
|
||||
}
|
||||
|
||||
const num = Number(slug);
|
||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
|
||||
return getPoyChapter(num);
|
||||
}
|
||||
|
||||
return null;
|
||||
return getContent(CODE, slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full sidebar navigation list.
|
||||
*
|
||||
* Order: front-pages → chapters 1–11
|
||||
*/
|
||||
export async function getPoySidebarItems(): Promise<PoySidebarItem[]> {
|
||||
const [meta, allDocs] = await Promise.all([
|
||||
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;
|
||||
return getSidebarItems(CODE);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user