Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c72ce814d | ||
|
|
32da38b24f | ||
|
|
5951eb5e18 | ||
|
|
ab1517f32f | ||
|
|
326ba090ca |
+146
-1
@@ -64,7 +64,12 @@ knowledgebase/
|
|||||||
│ ├── disciples/ → 4,747 initiated disciples directory
|
│ ├── disciples/ → 4,747 initiated disciples directory
|
||||||
│ │ ├── page.tsx → Disciples browser (letter nav, suffix filter, search, sort, pagination)
|
│ │ ├── page.tsx → Disciples browser (letter nav, suffix filter, search, sort, pagination)
|
||||||
│ │ └── disciples-browser.tsx → Client component
|
│ │ └── disciples-browser.tsx → Client component
|
||||||
│ ├── transcriptions/page.tsx → Coming Soon page
|
│ ├── transcriptions/ → 3,787 transcriptions (lectures, conversations, philosophy, dictations)
|
||||||
|
│ │ ├── page.tsx → Browse by year / place / type (3 views)
|
||||||
|
│ │ ├── year/[year]/page.tsx → Year listing, grouped by month
|
||||||
|
│ │ ├── place/[place]/page.tsx → Place listing
|
||||||
|
│ │ ├── type/[type]/page.tsx → Type listing (lecture/conversation), year sidebar
|
||||||
|
│ │ └── [slug]/page.tsx → Individual transcription reading page
|
||||||
│ └── api/
|
│ └── api/
|
||||||
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
|
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
|
||||||
│ ├── books/ → Book/verse API routes
|
│ ├── books/ → Book/verse API routes
|
||||||
@@ -83,6 +88,11 @@ knowledgebase/
|
|||||||
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
|
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
|
||||||
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
|
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
|
||||||
│ ├── site-footer.tsx → Global footer (About + Home links)
|
│ ├── site-footer.tsx → Global footer (About + Home links)
|
||||||
|
│ ├── audio-player/ → Site-wide floating audio player
|
||||||
|
│ │ ├── audio-context.tsx → React context + provider, manages <audio> element
|
||||||
|
│ │ ├── floating-player.tsx → Floating player UI (desktop bar / mobile pill)
|
||||||
|
│ │ ├── audio-wave.tsx → Reusable animated audio wave bars
|
||||||
|
│ │ └── index.ts → Barrel exports
|
||||||
│ ├── logo.tsx → Theme-aware logo component
|
│ ├── logo.tsx → Theme-aware logo component
|
||||||
│ └── theme-provider.tsx → next-themes wrapper
|
│ └── theme-provider.tsx → next-themes wrapper
|
||||||
├── lib/
|
├── lib/
|
||||||
@@ -94,6 +104,7 @@ knowledgebase/
|
|||||||
│ ├── source-reference.ts → Source reference from URL (for copy attribution)
|
│ ├── source-reference.ts → Source reference from URL (for copy attribution)
|
||||||
│ ├── disciples.ts → Disciples data access (by letter, place, year, search)
|
│ ├── disciples.ts → Disciples data access (by letter, place, year, search)
|
||||||
│ ├── letters.ts → Letters data access (by year, recipient, location)
|
│ ├── letters.ts → Letters data access (by year, recipient, location)
|
||||||
|
│ ├── transcriptions.ts → Transcriptions data access (reads from Prisma/MongoDB)
|
||||||
│ ├── utils.ts → cn() helper
|
│ ├── utils.ts → cn() helper
|
||||||
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.)
|
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.)
|
||||||
│ ├── other-documents.ts → Other Documents data access
|
│ ├── other-documents.ts → Other Documents data access
|
||||||
@@ -313,6 +324,39 @@ model Disciple {
|
|||||||
@@index([tags])
|
@@index([tags])
|
||||||
@@map("disciples")
|
@@map("disciples")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContentBlock {
|
||||||
|
text String
|
||||||
|
format String @default("paragraph")
|
||||||
|
alignment String @default("left")
|
||||||
|
speaker String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Transcription {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
type String // "lecture" | "conversation"
|
||||||
|
date String?
|
||||||
|
year Int?
|
||||||
|
month Int?
|
||||||
|
day Int?
|
||||||
|
place String?
|
||||||
|
context String?
|
||||||
|
reference String?
|
||||||
|
summary String?
|
||||||
|
content ContentBlock[]
|
||||||
|
tags String[]
|
||||||
|
participants String[]
|
||||||
|
language String @default("en")
|
||||||
|
source String?
|
||||||
|
durationMinutes Int?
|
||||||
|
audioUrl String?
|
||||||
|
isPublished Boolean @default(false)
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
@@map("transcriptions")
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -463,11 +507,27 @@ Seed script: `npx tsx scripts/seed-disciples.ts`
|
|||||||
- Multi-recipient groups are split by `,`, `;`, ` and `, ` & `
|
- Multi-recipient groups are split by `,`, `;`, ` and `, ` & `
|
||||||
- Trailing punctuation (`:`, `,`, `.`) cleaned from names
|
- Trailing punctuation (`:`, `,`, `.`) cleaned from names
|
||||||
|
|
||||||
|
### Transcriptions (stored in `transcriptions` collection)
|
||||||
|
|
||||||
|
3,787 transcriptions of Śrīla Prabhupāda's lectures and conversations (1966–1977), plus 59 philosophy discussions and 12 SB dictations.
|
||||||
|
|
||||||
|
- **Lectures** (2,179): Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, Nectar of Devotion, Īśopaniṣad, Brahma-saṁhitā, and general lectures
|
||||||
|
- **Conversations** (1,537): Morning walks, room conversations, interviews, press conferences, initiations
|
||||||
|
- **Philosophy Discussions** (59): Western philosophers discussed with Śyāmasundara and Hayagrīva
|
||||||
|
- **SB Dictations** (12): Śrīmad-Bhāgavatam 3rd Canto translation/purport dictations
|
||||||
|
- **Audio**: 99.1% have linked audio recordings from Cloudflare R2 (`audioUrl` field)
|
||||||
|
- **Empty content**: 30 transcriptions (Hindi/Bengali lectures) have no English text but may have audio
|
||||||
|
|
||||||
|
Data layer: `lib/transcriptions.ts` (reads from Prisma/MongoDB, NOT local JSON files)
|
||||||
|
|
||||||
|
Seed script: `cd knowledgebase && npx tsx scripts/seed-transcriptions.ts`
|
||||||
|
|
||||||
### Other
|
### Other
|
||||||
|
|
||||||
| Content | Collection |
|
| Content | Collection |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| Glossary (2,749 entries) | `glossary` |
|
| Glossary (2,749 entries) | `glossary` |
|
||||||
|
| Transcriptions (3,787 entries) | `transcriptions` collection |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -582,6 +642,27 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
|
|||||||
| `/letters/location/Los%20Angeles` | All letters from a specific location |
|
| `/letters/location/Los%20Angeles` | All letters from a specific location |
|
||||||
| `/letters/[slug]` | Individual letter reader with prev/next navigation |
|
| `/letters/[slug]` | Individual letter reader with prev/next navigation |
|
||||||
|
|
||||||
|
### Transcriptions
|
||||||
|
|
||||||
|
```
|
||||||
|
/transcriptions → Browse by year / place / type
|
||||||
|
/transcriptions?view=year → Year grid view
|
||||||
|
/transcriptions?view=place → Place grid view
|
||||||
|
/transcriptions?view=type → Lecture vs Conversation cards
|
||||||
|
/transcriptions/year/[year] → Year listing, grouped by month
|
||||||
|
/transcriptions/place/[place] → Place listing
|
||||||
|
/transcriptions/type/[type] → Type listing (lecture/conversation), year sidebar
|
||||||
|
/transcriptions/[slug] → Individual transcription reading page
|
||||||
|
```
|
||||||
|
|
||||||
|
Each transcription page shows:
|
||||||
|
- Title, type badge (Lecture/Conversation), date, place, reference, duration
|
||||||
|
- Participants list
|
||||||
|
- Play Audio button (links to Cloudflare R2 mp3)
|
||||||
|
- Content blocks: paragraphs with speaker labels, verses (centered italic), quotes (border-left), notes
|
||||||
|
- Prev/next navigation within same year
|
||||||
|
- `?hl=` text highlight sharing support with OG image
|
||||||
|
|
||||||
### Other routes
|
### Other routes
|
||||||
|
|
||||||
| Route | Content |
|
| Route | Content |
|
||||||
@@ -685,6 +766,28 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
|||||||
- All 104 content pages have `generateMetadata` with contextual fallback descriptions
|
- All 104 content pages have `generateMetadata` with contextual fallback descriptions
|
||||||
- When `?hl=` is present, the actual selected text is extracted from DB content for the OG quote
|
- When `?hl=` is present, the actual selected text is extracted from DB content for the OG quote
|
||||||
|
|
||||||
|
### Audio Player
|
||||||
|
|
||||||
|
A site-wide floating audio player for transcription recordings (and future music library). Built with framer-motion for animations.
|
||||||
|
|
||||||
|
- **Desktop**: Centered floating bar (max-width 680px) with cover image, track info, transport controls (prev/play/next), volume slider, source link, close button
|
||||||
|
- **Mobile**: Auto-minimizes to a floating pill (cover image with circular progress ring and audio wave animation) after 8 seconds of inactivity. Tap to expand. Full player shows two rows: track info row + centered transport controls
|
||||||
|
- **Z-index**: `z-40` — below text selection toolbar (z-80), drawers (z-90), modals (z-100)
|
||||||
|
- **Hidden when selection toolbar is active** on mobile to avoid overlap
|
||||||
|
- **Forced dark surface** (`bg-[#1c1c1e]`) for accessibility contrast regardless of theme
|
||||||
|
- **Keyboard shortcuts**: Space (play/pause), ←/→ (seek ±10s), ↑/↓ (volume), M (mute), Esc (close)
|
||||||
|
- **Progress seeker**: 8px tall bar at top of player, click/drag to seek
|
||||||
|
- **Volume**: Persisted to localStorage. Mobile relies on hardware volume buttons
|
||||||
|
- **framer-motion**: Spring animations for enter/exit, AnimatePresence for minimize/expand transitions, motion.div for audio wave bars
|
||||||
|
|
||||||
|
Components:
|
||||||
|
- `components/audio-player/audio-context.tsx` — React context + provider, manages `<audio>` element
|
||||||
|
- `components/audio-player/floating-player.tsx` — Floating player UI
|
||||||
|
- `components/audio-player/audio-wave.tsx` — Reusable animated audio wave bars
|
||||||
|
- `components/audio-player/index.ts` — Barrel exports
|
||||||
|
|
||||||
|
The `AudioPlayerProvider` wraps the entire app in `layout.tsx`. The `FloatingPlayer` is rendered before `SiteFooter`.
|
||||||
|
|
||||||
### Site Footer
|
### Site Footer
|
||||||
- Global footer on every page with © year, About link, Home link
|
- Global footer on every page with © year, About link, Home link
|
||||||
|
|
||||||
@@ -803,6 +906,42 @@ make run # Build + start Go API on :8080
|
|||||||
|
|
||||||
Glossary is browsed via `/glossary/{letter}` routes (A-Z). Each letter page shows terms grouped by base letter (diacritical variants mapped: Ā→A, Ś→S, Ṛ→R, etc.). Single entry view with prev/next navigation.
|
Glossary is browsed via `/glossary/{letter}` routes (A-Z). Each letter page shows terms grouped by base letter (diacritical variants mapped: Ā→A, Ś→S, Ṛ→R, etc.). Single entry view with prev/next navigation.
|
||||||
|
|
||||||
|
## Transcription JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "720911sb-los-angeles",
|
||||||
|
"name": "Śrīmad-Bhāgavatam 1.2.6",
|
||||||
|
"type": "lecture",
|
||||||
|
"date": "September 11, 1972",
|
||||||
|
"year": 1972,
|
||||||
|
"month": 9,
|
||||||
|
"day": 11,
|
||||||
|
"place": "Los Angeles",
|
||||||
|
"context": null,
|
||||||
|
"reference": "SB 1.2.6",
|
||||||
|
"durationMinutes": 45,
|
||||||
|
"participants": ["Prabhupāda", "Devotees"],
|
||||||
|
"audioUrl": "https://s3.prabhupadasays.com/sp-audio/...",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "So Kṛṣṇa consciousness movement...",
|
||||||
|
"format": "paragraph",
|
||||||
|
"alignment": "left",
|
||||||
|
"speaker": "Prabhupāda"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "<i>oṁ namo bhagavate...</i>",
|
||||||
|
"format": "verse",
|
||||||
|
"alignment": "center",
|
||||||
|
"speaker": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `"quote"` (translation/purport), `"heading"` (TEXT/TRANSLATION/PURPORT labels), `"note"` (editorial notes).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
@@ -818,3 +957,9 @@ Glossary is browsed via `/glossary/{letter}` routes (A-Z). Each letter page show
|
|||||||
5. **CSS zoom for font scaling**: `zoom` property works in Chrome/Safari/Edge but may not work in older Firefox (< 126). A `transform: scale()` fallback is provided via `@supports not (zoom: 1.1)`.
|
5. **CSS zoom for font scaling**: `zoom` property works in Chrome/Safari/Edge but may not work in older Firefox (< 126). A `transform: scale()` fallback is provided via `@supports not (zoom: 1.1)`.
|
||||||
|
|
||||||
6. **Password protection requires ACCESS_KEY at runtime**: The `PasswordGate` component checks the server at runtime via `/api/verify-access`. The env var must be available when the server is running, not just at build time.
|
6. **Password protection requires ACCESS_KEY at runtime**: The `PasswordGate` component checks the server at runtime via `/api/verify-access`. The env var must be available when the server is running, not just at build time.
|
||||||
|
|
||||||
|
7. **Transcription BSON fallback** — 1 document stores content as `contentJson` string due to BSON wire protocol limits; `getTranscription()` transparently parses it
|
||||||
|
|
||||||
|
8. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
|
||||||
|
|
||||||
|
9. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { getBookMeta } from "@/lib/api";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
{ params }: { params: Promise<{ book: string }> }
|
{ params }: { params: Promise<{ book: string }> },
|
||||||
) {
|
) {
|
||||||
const { book } = await params;
|
const { book } = await params;
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book } });
|
const meta = await getBookMeta(book);
|
||||||
if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
return NextResponse.json({ book: meta.book, ...meta.data as Record<string, unknown> });
|
return NextResponse.json(meta);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
|
getSingleVerse,
|
||||||
|
getChapterVerseIndex,
|
||||||
|
getChapterVerses,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -7,7 +11,7 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { book } = await params;
|
const { book } = await params;
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const division = url.searchParams.get("division");
|
const division = url.searchParams.get("division") || null;
|
||||||
const chapterStr = url.searchParams.get("chapter");
|
const chapterStr = url.searchParams.get("chapter");
|
||||||
const verseStr = url.searchParams.get("verse");
|
const verseStr = url.searchParams.get("verse");
|
||||||
const indexOnly = url.searchParams.get("indexOnly") === "true";
|
const indexOnly = url.searchParams.get("indexOnly") === "true";
|
||||||
@@ -22,20 +26,18 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chapter = parseInt(chapterStr);
|
const chapter = parseInt(chapterStr);
|
||||||
const where: Record<string, unknown> = { book, chapter };
|
|
||||||
|
|
||||||
if (book === "bg") {
|
const effectiveDivision = book === "bg" ? null : division;
|
||||||
where.division = null;
|
|
||||||
} else if (division) {
|
|
||||||
where.division = division;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Single verse mode: ?verse=N ---
|
// --- Single verse mode: ?verse=N ---
|
||||||
if (verseStr) {
|
if (verseStr) {
|
||||||
const verseNum = parseInt(verseStr);
|
const verseNum = parseInt(verseStr);
|
||||||
const verse = await prisma.verse.findFirst({
|
const verse = await getSingleVerse(
|
||||||
where: { ...where, verse: verseNum },
|
book,
|
||||||
});
|
effectiveDivision,
|
||||||
|
chapter,
|
||||||
|
verseNum,
|
||||||
|
);
|
||||||
|
|
||||||
if (!verse) {
|
if (!verse) {
|
||||||
return NextResponse.json({ error: "Verse not found" }, { status: 404 });
|
return NextResponse.json({ error: "Verse not found" }, { status: 404 });
|
||||||
@@ -46,15 +48,7 @@ export async function GET(
|
|||||||
|
|
||||||
// --- Index-only mode: return lightweight verse list for the sidebar ---
|
// --- Index-only mode: return lightweight verse list for the sidebar ---
|
||||||
if (indexOnly) {
|
if (indexOnly) {
|
||||||
const verses = await prisma.verse.findMany({
|
const verses = await getChapterVerseIndex(book, effectiveDivision, chapter);
|
||||||
where,
|
|
||||||
orderBy: { verse: "asc" },
|
|
||||||
select: {
|
|
||||||
verse: true,
|
|
||||||
verseEnd: true,
|
|
||||||
reference: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ verses, total: verses.length });
|
return NextResponse.json({ verses, total: verses.length });
|
||||||
}
|
}
|
||||||
@@ -62,24 +56,14 @@ export async function GET(
|
|||||||
// --- Paginated mode: return full verse data in pages ---
|
// --- Paginated mode: return full verse data in pages ---
|
||||||
const page = Math.max(1, parseInt(pageStr || "1"));
|
const page = Math.max(1, parseInt(pageStr || "1"));
|
||||||
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
|
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
|
|
||||||
const [verses, total] = await Promise.all([
|
const result = await getChapterVerses(
|
||||||
prisma.verse.findMany({
|
book,
|
||||||
where,
|
effectiveDivision,
|
||||||
orderBy: { verse: "asc" },
|
chapter,
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
}),
|
|
||||||
prisma.verse.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
verses,
|
|
||||||
total,
|
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
totalPages: Math.ceil(total / limit),
|
);
|
||||||
hasMore: skip + verses.length < total,
|
|
||||||
});
|
return NextResponse.json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { getAllBooks } from "@/lib/api";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const metas = await prisma.bookMeta.findMany();
|
const books = await getAllBooks();
|
||||||
return NextResponse.json(metas.map((m) => ({ book: m.book, ...m.data as Record<string, unknown> })));
|
return NextResponse.json(books);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { getAllGlossaryEntries } from "@/lib/api";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const entries = await prisma.glossaryEntry.findMany({
|
const entries = await getAllGlossaryEntries();
|
||||||
orderBy: { term: "asc" },
|
|
||||||
});
|
|
||||||
return NextResponse.json(entries);
|
return NextResponse.json(entries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function BbdPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -162,14 +162,7 @@ function SidebarLink({
|
|||||||
item: BkSidebarItem;
|
item: BkSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ export default async function BsPage() {
|
|||||||
{introduction.title}
|
{introduction.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{introduction.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -133,7 +132,6 @@ export default async function BsPage() {
|
|||||||
{foreword.title}
|
{foreword.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{foreword.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -162,14 +162,7 @@ function SidebarLink({
|
|||||||
item: CbhcSidebarItem;
|
item: CbhcSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export default async function DsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export default async function EjPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function EkPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -217,14 +217,7 @@ function SidebarLink({
|
|||||||
item: GgSidebarItem;
|
item: GgSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export default async function GgPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -126,7 +125,6 @@ export default async function GgPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function HkhPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -50,8 +50,11 @@ export async function generateMetadata({
|
|||||||
const isVerse_ = content && "mantra" in content && "devanagari" in content;
|
const isVerse_ = content && "mantra" in content && "devanagari" in content;
|
||||||
const paragraphs = content
|
const paragraphs = content
|
||||||
? isVerse_
|
? isVerse_
|
||||||
? [(content as IsoVerse).translation, ...(content as IsoVerse).purport]
|
? [
|
||||||
: (content as IsoIntroSegment).paragraphs
|
(content as unknown as IsoVerse).translation,
|
||||||
|
...(content as unknown as IsoVerse).purport,
|
||||||
|
]
|
||||||
|
: (content as unknown as IsoIntroSegment).paragraphs
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||||
@@ -64,7 +67,7 @@ export async function generateMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const title = isVerse_
|
const title = isVerse_
|
||||||
? `${(content as IsoVerse).reference}: ${(content as IsoVerse).title}`
|
? `${(content as unknown as IsoVerse).reference}`
|
||||||
: content && "title" in content
|
: content && "title" in content
|
||||||
? (content as IsoIntroSegment).title
|
? (content as IsoIntroSegment).title
|
||||||
: `Mantra ${slug}`;
|
: `Mantra ${slug}`;
|
||||||
@@ -110,7 +113,7 @@ export default async function IsoMantraPage({
|
|||||||
{verse && (
|
{verse && (
|
||||||
<>
|
<>
|
||||||
<span className="text-primary">{verse.reference}: </span>
|
<span className="text-primary">{verse.reference}: </span>
|
||||||
{verse.title}
|
{verse.bookTitle}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{intro && intro.title}
|
{intro && intro.title}
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export default async function IsoPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{frontPages.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -85,7 +84,6 @@ export default async function IsoPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{introduction.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -164,7 +162,6 @@ export default async function IsoPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{aboutAuthor.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export default async function KbVolumePage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -106,7 +105,6 @@ export default async function KbVolumePage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{ch.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -221,14 +221,7 @@ function SidebarLink({
|
|||||||
item: LclSidebarItem;
|
item: LclSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export default async function LclPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -126,7 +125,6 @@ export default async function LclPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function LobPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function MgPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ export default async function MmPage() {
|
|||||||
{introduction.title}
|
{introduction.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{introduction.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function MogPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ export default async function NbsPage() {
|
|||||||
{introduction.title}
|
{introduction.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{introduction.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -203,14 +203,7 @@ function SidebarLink({
|
|||||||
item: NodSidebarItem;
|
item: NodSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export default async function NodPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export default async function NoiPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{frontPages.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -82,7 +81,6 @@ export default async function NoiPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{preface.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -141,7 +139,6 @@ export default async function NoiPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{appendixes.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function OchkmPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function OwkPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function PoyPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -239,14 +239,7 @@ function SidebarLink({
|
|||||||
item: PqpaSidebarItem;
|
item: PqpaSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export default async function PqpaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -151,7 +150,6 @@ export default async function PqpaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function RvPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export default async function SsrPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -123,7 +122,6 @@ export default async function SsrPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export default async function SvaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -123,7 +122,6 @@ export default async function SvaPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -141,6 +141,31 @@ function TlcSidebar({
|
|||||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||||
|
|
||||||
|
// Group chapters by part (if parts exist)
|
||||||
|
const hasParts = chapterItems.some((item) => item.partNumber != null);
|
||||||
|
|
||||||
|
const partGroups: {
|
||||||
|
partNumber: number;
|
||||||
|
partTitle: string;
|
||||||
|
chapters: TlcSidebarItem[];
|
||||||
|
}[] = [];
|
||||||
|
if (hasParts) {
|
||||||
|
const partMap = new Map<
|
||||||
|
number,
|
||||||
|
{ partTitle: string; chapters: TlcSidebarItem[] }
|
||||||
|
>();
|
||||||
|
for (const item of chapterItems) {
|
||||||
|
const pn = item.partNumber ?? 0;
|
||||||
|
const pt = item.partTitle ?? `Part ${pn}`;
|
||||||
|
if (!partMap.has(pn)) partMap.set(pn, { partTitle: pt, chapters: [] });
|
||||||
|
partMap.get(pn)!.chapters.push(item);
|
||||||
|
}
|
||||||
|
for (const [partNumber, { partTitle, chapters }] of partMap) {
|
||||||
|
partGroups.push({ partNumber, partTitle, chapters });
|
||||||
|
}
|
||||||
|
partGroups.sort((a, b) => a.partNumber - b.partNumber);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||||
{/* Front Matter */}
|
{/* Front Matter */}
|
||||||
@@ -161,8 +186,25 @@ function TlcSidebar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chapters */}
|
{/* Chapters — grouped by part if available */}
|
||||||
{chapterItems.length > 0 && (
|
{hasParts
|
||||||
|
? partGroups.map((group) => (
|
||||||
|
<div key={group.partNumber} className="mb-4">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||||
|
{group.partTitle}
|
||||||
|
</h2>
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{group.chapters.map((item) => (
|
||||||
|
<SidebarLink
|
||||||
|
key={item.slug}
|
||||||
|
item={item}
|
||||||
|
isActive={item.slug === currentSlug}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: chapterItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||||
Chapters
|
Chapters
|
||||||
@@ -189,14 +231,7 @@ function SidebarLink({
|
|||||||
item: TlcSidebarItem;
|
item: TlcSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export default async function TlcPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -226,14 +226,7 @@ function SidebarLink({
|
|||||||
item: TlkSidebarItem;
|
item: TlkSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export default async function TlkPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -126,7 +125,6 @@ export default async function TlkPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -226,14 +226,7 @@ function SidebarLink({
|
|||||||
item: TqkSidebarItem;
|
item: TqkSidebarItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
let label: string;
|
const label = item.label;
|
||||||
if (item.type === "chapter" && item.chapter != null) {
|
|
||||||
// Extract just the title portion after "Chapter N: "
|
|
||||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
|
||||||
label = `${item.chapter}. ${titlePart}`;
|
|
||||||
} else {
|
|
||||||
label = item.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export default async function TqkPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -126,7 +125,6 @@ export default async function TqkPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||||
{seg.paragraphs.length} paragraphs
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+11
-27
@@ -10,8 +10,7 @@ import { buttonVariants } from "@/components/ui/button";
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||||
import { prisma } from "@/lib/db";
|
import { bff, bffList } from "@/lib/bff";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HTML sanitisation (same pattern used across all readers)
|
// HTML sanitisation (same pattern used across all readers)
|
||||||
@@ -31,25 +30,6 @@ function sanitizeHtml(html: string): string {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Data fetching
|
// Data fetching
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -69,7 +49,14 @@ type SidebarItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function getEssay(slug: string): Promise<EssayData | null> {
|
async function getEssay(slug: string): Promise<EssayData | null> {
|
||||||
const doc = await prisma.essay.findUnique({ where: { slug } });
|
const doc = await bff<{
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
year: number | null;
|
||||||
|
group: string | null;
|
||||||
|
paragraphs: string[];
|
||||||
|
}>(`/essays/${slug}`);
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
return {
|
return {
|
||||||
slug: doc.slug,
|
slug: doc.slug,
|
||||||
@@ -77,15 +64,12 @@ async function getEssay(slug: string): Promise<EssayData | null> {
|
|||||||
author: doc.author,
|
author: doc.author,
|
||||||
year: doc.year,
|
year: doc.year,
|
||||||
group: doc.group,
|
group: doc.group,
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
paragraphs: (doc.paragraphs ?? []).map(sanitizeHtml),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEssaySidebar(): Promise<SidebarItem[]> {
|
async function getEssaySidebar(): Promise<SidebarItem[]> {
|
||||||
return prisma.essay.findMany({
|
return bffList<SidebarItem>("/essays");
|
||||||
orderBy: [{ group: "asc" }, { order: "asc" }],
|
|
||||||
select: { slug: true, title: true },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+7
-5
@@ -4,7 +4,7 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { buildOgMeta } from "@/lib/og";
|
import { buildOgMeta } from "@/lib/og";
|
||||||
import { prisma } from "@/lib/db";
|
import { bffList } from "@/lib/bff";
|
||||||
import { EssaysGrid } from "./essays-grid";
|
import { EssaysGrid } from "./essays-grid";
|
||||||
|
|
||||||
export function generateMetadata(): Metadata {
|
export function generateMetadata(): Metadata {
|
||||||
@@ -17,10 +17,12 @@ export function generateMetadata(): Metadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getAllEssays() {
|
async function getAllEssays() {
|
||||||
return prisma.essay.findMany({
|
return bffList<{
|
||||||
orderBy: [{ group: "asc" }, { order: "asc" }],
|
slug: string;
|
||||||
select: { slug: true, title: true, year: true, group: true },
|
title: string;
|
||||||
});
|
year: number | null;
|
||||||
|
group: string | null;
|
||||||
|
}>("/essays");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function EssaysPage() {
|
export default async function EssaysPage() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
getTranscription,
|
getTranscription,
|
||||||
getAdjacentTranscriptions,
|
getAdjacentTranscriptions,
|
||||||
} from "@/lib/transcriptions";
|
} from "@/lib/transcriptions";
|
||||||
import { buildOgMeta } from "@/lib/og";
|
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -18,27 +18,51 @@ import { PlayAudioButton } from "./play-audio-button";
|
|||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
const t = await getTranscription(slug);
|
const t = await getTranscription(slug);
|
||||||
if (!t) return {};
|
if (!t) return {};
|
||||||
|
|
||||||
const detail = [t.date, t.place].filter(Boolean).join(" · ");
|
const detail = [t.date, t.place].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
|
// Build paragraphs from content blocks for hl matching
|
||||||
|
const paragraphs = t.content.map((b) => {
|
||||||
|
const speakerPrefix = b.speaker ? `${b.speaker}: ` : "";
|
||||||
|
return speakerPrefix + b.text.replace(/<[^>]+>/g, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
const hlQuote = extractQuoteFromHl(sp, paragraphs);
|
||||||
|
|
||||||
|
if (hlQuote) {
|
||||||
|
return buildOgMeta({
|
||||||
|
title: `Transcripts: ${t.name}`,
|
||||||
|
detail: detail || undefined,
|
||||||
|
quote: hlQuote,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: find a good Prabhupāda quote from the content
|
||||||
|
const spQuote = t.content
|
||||||
|
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
|
||||||
|
?.text?.replace(/<[^>]+>/g, "")
|
||||||
|
.slice(0, 200);
|
||||||
|
|
||||||
|
if (spQuote) {
|
||||||
return buildOgMeta({
|
return buildOgMeta({
|
||||||
title: t.name,
|
title: t.name,
|
||||||
detail: detail || undefined,
|
detail: detail || undefined,
|
||||||
quote:
|
quote: spQuote,
|
||||||
t.content.length > 0
|
|
||||||
? t.content
|
|
||||||
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
|
|
||||||
?.text?.replace(/<[^>]+>/g, "")
|
|
||||||
.slice(0, 200)
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ogForTranscription(`Transcripts: ${t.name}`, detail || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function TranscriptionPage({
|
export default async function TranscriptionPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player";
|
import {
|
||||||
|
useAudioPlayer,
|
||||||
|
type AudioTrack,
|
||||||
|
AudioWaveAnimation,
|
||||||
|
} from "@/components/audio-player";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Play, Pause, Radio } from "lucide-react";
|
import { Play, Pause } from "lucide-react";
|
||||||
|
|
||||||
type PlayAudioButtonProps = {
|
type PlayAudioButtonProps = {
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
@@ -63,7 +67,11 @@ export function PlayAudioButton({
|
|||||||
aria-label={isThisPlaying ? "Pause" : "Play audio"}
|
aria-label={isThisPlaying ? "Pause" : "Play audio"}
|
||||||
>
|
>
|
||||||
{isThisPlaying ? (
|
{isThisPlaying ? (
|
||||||
<Pause className="size-3.5" />
|
<AudioWaveAnimation
|
||||||
|
paused={false}
|
||||||
|
colorClassName="bg-primary-foreground"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Play className="size-3.5 ml-0.5" />
|
<Play className="size-3.5 ml-0.5" />
|
||||||
)}
|
)}
|
||||||
@@ -85,7 +93,11 @@ export function PlayAudioButton({
|
|||||||
>
|
>
|
||||||
{isThisPlaying ? (
|
{isThisPlaying ? (
|
||||||
<>
|
<>
|
||||||
<Radio className="size-4 animate-pulse" />
|
<AudioWaveAnimation
|
||||||
|
paused={false}
|
||||||
|
colorClassName="bg-primary-foreground"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
<span>Now Playing</span>
|
<span>Now Playing</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const dynamic = "force-dynamic";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { buildOgMeta } from "@/lib/og";
|
import { ogForTranscriptions } from "@/lib/og";
|
||||||
import {
|
import {
|
||||||
getTranscriptionYears,
|
getTranscriptionYears,
|
||||||
getTranscriptionPlaces,
|
getTranscriptionPlaces,
|
||||||
@@ -14,12 +14,7 @@ import { TranscriptionsNav } from "./transcriptions-nav";
|
|||||||
import { TranscriptionsBrowser } from "./transcriptions-browser";
|
import { TranscriptionsBrowser } from "./transcriptions-browser";
|
||||||
|
|
||||||
export function generateMetadata(): Metadata {
|
export function generateMetadata(): Metadata {
|
||||||
return buildOgMeta({
|
return ogForTranscriptions();
|
||||||
title: "Transcriptions",
|
|
||||||
detail: "3,700+ lectures & conversations (1966–1977)",
|
|
||||||
quote:
|
|
||||||
"Transcriptions of Śrīla Prabhupāda's lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function TranscriptionsPage({
|
export default async function TranscriptionsPage({
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { buildOgMeta } from "@/lib/og";
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock, MapPin } from "lucide-react";
|
||||||
|
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -60,7 +61,7 @@ export default async function TranscriptionsByPlacePage({
|
|||||||
<Link
|
<Link
|
||||||
key={t.slug}
|
key={t.slug}
|
||||||
href={`/transcriptions/${t.slug}`}
|
href={`/transcriptions/${t.slug}`}
|
||||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
|
className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -87,6 +88,16 @@ export default async function TranscriptionsByPlacePage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{t.audioUrl && (
|
||||||
|
<PlayAudioButton
|
||||||
|
audioUrl={t.audioUrl}
|
||||||
|
title={t.name}
|
||||||
|
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||||
|
sourceUrl={`/transcriptions/${t.slug}`}
|
||||||
|
type={t.type}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { buttonVariants } from "@/components/ui/button";
|
|||||||
import { ReadingSettings } from "@/components/reading-settings";
|
import { ReadingSettings } from "@/components/reading-settings";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
|
||||||
|
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -34,6 +35,7 @@ export type TranscriptionItem = {
|
|||||||
reference: string | null;
|
reference: string | null;
|
||||||
durationMinutes: number | null;
|
durationMinutes: number | null;
|
||||||
participants: string[];
|
participants: string[];
|
||||||
|
audioUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type YearGroup = {
|
type YearGroup = {
|
||||||
@@ -364,7 +366,7 @@ function MonthSection({
|
|||||||
<li key={t.slug}>
|
<li key={t.slug}>
|
||||||
<Link
|
<Link
|
||||||
href={`/transcriptions/${t.slug}`}
|
href={`/transcriptions/${t.slug}`}
|
||||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 pl-12 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
|
className="flex items-center px-5 pl-12 py-3.5 gap-3 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -396,6 +398,16 @@ function MonthSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{t.audioUrl && (
|
||||||
|
<PlayAudioButton
|
||||||
|
audioUrl={t.audioUrl}
|
||||||
|
title={t.name}
|
||||||
|
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||||
|
sourceUrl={`/transcriptions/${t.slug}`}
|
||||||
|
type={t.type}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export async function generateMetadata({
|
|||||||
const transcriptions = await getTranscriptionsByYear(year);
|
const transcriptions = await getTranscriptionsByYear(year);
|
||||||
return buildOgMeta({
|
return buildOgMeta({
|
||||||
title: "Transcriptions",
|
title: "Transcriptions",
|
||||||
detail: `${transcriptions.length} transcriptions from ${year}`,
|
detail: `${transcriptions.length} lectures & conversations from ${year}`,
|
||||||
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year}.`,
|
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year} — the living voice of the Founder-Ācārya preserved in text.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useCallback, useEffect } from "react";
|
import { useRef, useCallback, useEffect, useState } from "react";
|
||||||
import { useAudioPlayer } from "./audio-context";
|
import { useAudioPlayer } from "./audio-context";
|
||||||
|
import { AudioWaveAnimation } from "./audio-wave";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -18,6 +20,64 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile auto-minimize hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MINIMIZE_DELAY = 8000;
|
||||||
|
|
||||||
|
function useIsMobile() {
|
||||||
|
const [mobile, setMobile] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(max-width: 767px)");
|
||||||
|
setMobile(mq.matches);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMobile(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns [minimized, setMinimized, resetTimer].
|
||||||
|
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity on mobile.
|
||||||
|
* Call `resetTimer()` on any user interaction to restart the countdown.
|
||||||
|
*/
|
||||||
|
function useAutoMinimize(hasTrack: boolean) {
|
||||||
|
const [minimized, setMinimized] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTimer = useCallback(() => {
|
||||||
|
clearTimer();
|
||||||
|
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
|
||||||
|
}, [clearTimer]);
|
||||||
|
|
||||||
|
const resetTimer = useCallback(() => {
|
||||||
|
setMinimized(false);
|
||||||
|
startTimer();
|
||||||
|
}, [startTimer]);
|
||||||
|
|
||||||
|
// Start timer when track begins
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasTrack) {
|
||||||
|
startTimer();
|
||||||
|
} else {
|
||||||
|
clearTimer();
|
||||||
|
setMinimized(false);
|
||||||
|
}
|
||||||
|
return clearTimer;
|
||||||
|
}, [hasTrack, startTimer, clearTimer]);
|
||||||
|
|
||||||
|
return { minimized, setMinimized, resetTimer } as const;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -230,6 +290,28 @@ export function FloatingPlayer() {
|
|||||||
} = useAudioPlayer();
|
} = useAudioPlayer();
|
||||||
|
|
||||||
const playerRef = useRef<HTMLDivElement>(null);
|
const playerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { minimized, setMinimized, resetTimer } =
|
||||||
|
useAutoMinimize(!!currentTrack);
|
||||||
|
|
||||||
|
// Watch for the selection toolbar appearing/disappearing in the DOM
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTrack) return;
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const toolbar = document.querySelector("[data-selection-toolbar]");
|
||||||
|
// The toolbar wrapper is always present; check if it has visible children
|
||||||
|
setSelectionToolbarVisible(!!toolbar && toolbar.childElementCount > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(check);
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [currentTrack]);
|
||||||
|
|
||||||
// Add bottom padding to body when player is visible
|
// Add bottom padding to body when player is visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -240,47 +322,81 @@ export function FloatingPlayer() {
|
|||||||
};
|
};
|
||||||
}, [currentTrack]);
|
}, [currentTrack]);
|
||||||
|
|
||||||
|
// Wrap interactions to reset the minimize timer
|
||||||
|
const interact = useCallback(
|
||||||
|
<T extends unknown[]>(fn: (...args: T) => void) =>
|
||||||
|
(...args: T) => {
|
||||||
|
resetTimer();
|
||||||
|
fn(...args);
|
||||||
|
},
|
||||||
|
[resetTimer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const iTogglePlayPause = interact(togglePlayPause);
|
||||||
|
const iSeekTo = interact(seekTo);
|
||||||
|
const iSetVolume = interact(setVolume);
|
||||||
|
const iToggleMute = interact(toggleMute);
|
||||||
|
const iNext = interact(next);
|
||||||
|
const iPrevious = interact(previous);
|
||||||
|
const iStop = interact(stop);
|
||||||
|
|
||||||
|
// Minimize when clicking/tapping outside the player
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTrack || minimized) return;
|
||||||
|
|
||||||
|
function handleOutsideClick(e: PointerEvent) {
|
||||||
|
const player = playerRef.current;
|
||||||
|
if (!player) return;
|
||||||
|
if (player.contains(e.target as Node)) return;
|
||||||
|
setMinimized(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handleOutsideClick);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("pointerdown", handleOutsideClick);
|
||||||
|
}, [currentTrack, minimized, setMinimized]);
|
||||||
|
|
||||||
// Keyboard shortcuts when player is focused
|
// Keyboard shortcuts when player is focused
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case " ":
|
case " ":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
togglePlayPause();
|
iTogglePlayPause();
|
||||||
break;
|
break;
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
seekTo(Math.max(0, currentTime - 10));
|
iSeekTo(Math.max(0, currentTime - 10));
|
||||||
break;
|
break;
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
seekTo(Math.min(duration, currentTime + 10));
|
iSeekTo(Math.min(duration, currentTime + 10));
|
||||||
break;
|
break;
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setVolume(Math.min(1, volume + 0.05));
|
iSetVolume(Math.min(1, volume + 0.05));
|
||||||
break;
|
break;
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setVolume(Math.max(0, volume - 0.05));
|
iSetVolume(Math.max(0, volume - 0.05));
|
||||||
break;
|
break;
|
||||||
case "m":
|
case "m":
|
||||||
case "M":
|
case "M":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleMute();
|
iToggleMute();
|
||||||
break;
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
stop();
|
iStop();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
togglePlayPause,
|
iTogglePlayPause,
|
||||||
seekTo,
|
iSeekTo,
|
||||||
setVolume,
|
iSetVolume,
|
||||||
toggleMute,
|
iToggleMute,
|
||||||
stop,
|
iStop,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
volume,
|
volume,
|
||||||
@@ -292,18 +408,105 @@ export function FloatingPlayer() {
|
|||||||
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
|
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
|
||||||
const hasPrev = currentIndex > 0;
|
const hasPrev = currentIndex > 0;
|
||||||
const coverSrc = currentTrack.coverImage || FALLBACK_COVER;
|
const coverSrc = currentTrack.coverImage || FALLBACK_COVER;
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
|
// ── Hide entirely when selection toolbar is active ──
|
||||||
|
if (selectionToolbarVisible) return null;
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
return (
|
return (
|
||||||
<div
|
<AnimatePresence mode="popLayout">
|
||||||
|
{minimized ? (
|
||||||
|
<motion.button
|
||||||
|
key="mini-pill"
|
||||||
|
data-audio-player
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMinimized(false);
|
||||||
|
resetTimer();
|
||||||
|
}}
|
||||||
|
aria-label="Expand audio player"
|
||||||
|
initial={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
bottom: selectionToolbarVisible ? 90 : 24,
|
||||||
|
}}
|
||||||
|
exit={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
||||||
|
transition={{ type: "spring", damping: 20, stiffness: 400 }}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-40 right-4",
|
||||||
|
"size-14 rounded-full",
|
||||||
|
"shadow-2xl shadow-black/30",
|
||||||
|
"overflow-hidden",
|
||||||
|
"ring-2 ring-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Cover image */}
|
||||||
|
<Image
|
||||||
|
src={coverSrc}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
{/* Dark overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
|
{/* Circular progress ring */}
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 size-full -rotate-90"
|
||||||
|
viewBox="0 0 56 56"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="28"
|
||||||
|
cy="28"
|
||||||
|
r="26"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
strokeOpacity={0.15}
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="28"
|
||||||
|
cy="28"
|
||||||
|
r="26"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
strokeOpacity={0.8}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray={2 * Math.PI * 26}
|
||||||
|
strokeDashoffset={2 * Math.PI * 26 * (1 - progress / 100)}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="transition-[stroke-dashoffset] duration-300"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Audio wave indicator */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<AudioWaveAnimation
|
||||||
|
paused={!isPlaying}
|
||||||
|
colorClassName="bg-white/90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="full-player"
|
||||||
ref={playerRef}
|
ref={playerRef}
|
||||||
data-audio-player
|
data-audio-player
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="Audio player"
|
aria-label="Audio player"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
initial={{ opacity: 0, bottom: -80 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
bottom: selectionToolbarVisible ? 64 : 20,
|
||||||
|
}}
|
||||||
|
exit={{ opacity: 0, bottom: -80 }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
// Centered floating bar
|
// Centered floating bar
|
||||||
"fixed z-40 bottom-5 left-1/2 -translate-x-1/2",
|
"fixed z-40 left-1/2 -translate-x-1/2",
|
||||||
// Narrower width
|
// Narrower width
|
||||||
"w-[calc(100%-2rem)] max-w-[680px]",
|
"w-[calc(100%-2rem)] max-w-[680px]",
|
||||||
// Forced dark surface for contrast regardless of theme
|
// Forced dark surface for contrast regardless of theme
|
||||||
@@ -311,8 +514,6 @@ export function FloatingPlayer() {
|
|||||||
"border border-white/10",
|
"border border-white/10",
|
||||||
"rounded-2xl",
|
"rounded-2xl",
|
||||||
"shadow-2xl shadow-black/25",
|
"shadow-2xl shadow-black/25",
|
||||||
// Animation
|
|
||||||
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300",
|
|
||||||
// Clip seeker to rounded top corners
|
// Clip seeker to rounded top corners
|
||||||
"overflow-hidden",
|
"overflow-hidden",
|
||||||
)}
|
)}
|
||||||
@@ -321,7 +522,7 @@ export function FloatingPlayer() {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
onSeek={seekTo}
|
onSeek={iSeekTo}
|
||||||
thin
|
thin
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -364,7 +565,7 @@ export function FloatingPlayer() {
|
|||||||
{/* Center: Transport controls */}
|
{/* Center: Transport controls */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={previous}
|
onClick={iPrevious}
|
||||||
disabled={!hasPrev && currentTime <= 3}
|
disabled={!hasPrev && currentTime <= 3}
|
||||||
label="Previous track"
|
label="Previous track"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@@ -373,7 +574,7 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={togglePlayPause}
|
onClick={iTogglePlayPause}
|
||||||
label={isPlaying ? "Pause" : "Play"}
|
label={isPlaying ? "Pause" : "Play"}
|
||||||
size="large"
|
size="large"
|
||||||
className="bg-white text-black hover:bg-white/90"
|
className="bg-white text-black hover:bg-white/90"
|
||||||
@@ -388,7 +589,7 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={next}
|
onClick={iNext}
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
label="Next track"
|
label="Next track"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@@ -403,7 +604,7 @@ export function FloatingPlayer() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleMute}
|
onClick={iToggleMute}
|
||||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||||
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
@@ -419,7 +620,7 @@ export function FloatingPlayer() {
|
|||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
value={isMuted ? 0 : Math.round(volume * 100)}
|
value={isMuted ? 0 : Math.round(volume * 100)}
|
||||||
onChange={(e) => setVolume(Number(e.target.value) / 100)}
|
onChange={(e) => iSetVolume(Number(e.target.value) / 100)}
|
||||||
aria-label="Volume"
|
aria-label="Volume"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
|
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
|
||||||
@@ -448,11 +649,11 @@ export function FloatingPlayer() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Close */}
|
{/* Minimize (playing) or Close (paused) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={stop}
|
onClick={isPlaying ? () => setMinimized(true) : iStop}
|
||||||
aria-label="Close player"
|
aria-label={isPlaying ? "Minimize player" : "Close player"}
|
||||||
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
@@ -461,7 +662,10 @@ export function FloatingPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Mobile layout ── */}
|
{/* ── Mobile layout ── */}
|
||||||
<div className="flex md:hidden flex-col px-4 py-3 gap-2.5">
|
<div
|
||||||
|
className="flex md:hidden flex-col px-4 py-3 gap-2.5"
|
||||||
|
onPointerDown={resetTimer}
|
||||||
|
>
|
||||||
{/* Time row — right below seeker */}
|
{/* Time row — right below seeker */}
|
||||||
<div className="flex items-center justify-between px-0.5">
|
<div className="flex items-center justify-between px-0.5">
|
||||||
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
||||||
@@ -508,11 +712,11 @@ export function FloatingPlayer() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Close */}
|
{/* Minimize (playing) or Close (paused) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={stop}
|
onClick={isPlaying ? () => setMinimized(true) : iStop}
|
||||||
aria-label="Close player"
|
aria-label={isPlaying ? "Minimize player" : "Close player"}
|
||||||
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
||||||
>
|
>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
@@ -522,7 +726,7 @@ export function FloatingPlayer() {
|
|||||||
{/* Row 2: Prev + Play/Pause + Next — centered */}
|
{/* Row 2: Prev + Play/Pause + Next — centered */}
|
||||||
<div className="flex items-center justify-center gap-4 py-1">
|
<div className="flex items-center justify-center gap-4 py-1">
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={previous}
|
onClick={iPrevious}
|
||||||
disabled={!hasPrev && currentTime <= 3}
|
disabled={!hasPrev && currentTime <= 3}
|
||||||
label="Previous track"
|
label="Previous track"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
||||||
@@ -531,7 +735,7 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={togglePlayPause}
|
onClick={iTogglePlayPause}
|
||||||
label={isPlaying ? "Pause" : "Play"}
|
label={isPlaying ? "Pause" : "Play"}
|
||||||
size="large"
|
size="large"
|
||||||
className="bg-white text-black hover:bg-white/90 !size-12"
|
className="bg-white text-black hover:bg-white/90 !size-12"
|
||||||
@@ -546,7 +750,7 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={next}
|
onClick={iNext}
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
label="Next track"
|
label="Next track"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
||||||
@@ -555,6 +759,8 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
|
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
|
||||||
export type { AudioTrack } from "./audio-context";
|
export type { AudioTrack } from "./audio-context";
|
||||||
export { FloatingPlayer } from "./floating-player";
|
export { FloatingPlayer } from "./floating-player";
|
||||||
|
export { AudioWaveAnimation } from "./audio-wave";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function SiteFooter() {
|
|||||||
<footer className="border-t border-border bg-card mt-auto">
|
<footer className="border-t border-border bg-card mt-auto">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
|
<div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
© {new Date().getFullYear()}{" "}
|
Team {" "}
|
||||||
<span className="font-medium text-foreground">Prabhupāda Says</span>
|
<span className="font-medium text-foreground">Prabhupāda Says</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
+211
-128
@@ -1,13 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for server components.
|
* Data access layer for server components.
|
||||||
*
|
*
|
||||||
* These functions query MongoDB via Prisma and are called directly by
|
* These functions call the Go BFF API server instead of querying the DB
|
||||||
* Next.js server components. The /api/* routes exist for external consumers
|
* directly. The BFF handles sanitization, pagination, and caching.
|
||||||
* and for client-side verse-navigation fetching.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { bff, bffList, bffOrThrow } from "@/lib/bff";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -33,6 +31,12 @@ export type VerseRecord = {
|
|||||||
synonyms: { word: string; meanings: string[] }[];
|
synonyms: { word: string; meanings: string[] }[];
|
||||||
translation: string;
|
translation: string;
|
||||||
purport: string[];
|
purport: string[];
|
||||||
|
/** Alias for bookTitle — used by ISO/BS/NOI pages */
|
||||||
|
title?: string;
|
||||||
|
/** Alias for verse number — used by NOI pages as "text number" */
|
||||||
|
text?: number;
|
||||||
|
/** Alias for verse/chapter number — used by ISO pages as "mantra number" */
|
||||||
|
mantra?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VerseIndexEntry = {
|
export type VerseIndexEntry = {
|
||||||
@@ -73,28 +77,133 @@ export type ProseChapterRecord = {
|
|||||||
paragraphs: string[];
|
paragraphs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTML sanitisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sanitizeHtml(html: string): string {
|
||||||
|
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||||
|
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||||
|
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
||||||
|
clean = clean.replace(
|
||||||
|
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||||
|
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
||||||
|
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeParagraphs(paragraphs: string[] | undefined): string[] {
|
||||||
|
if (!paragraphs || !Array.isArray(paragraphs)) return [];
|
||||||
|
return paragraphs.map(sanitizeHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a BFF path for verse-related endpoints.
|
||||||
|
* Books like BG and SB1962 have no division (null), so we skip it.
|
||||||
|
* Books like SB and CC have a division (canto / līlā).
|
||||||
|
*/
|
||||||
|
function versePath(
|
||||||
|
book: string,
|
||||||
|
division: string | null,
|
||||||
|
chapter: number,
|
||||||
|
): string {
|
||||||
|
if (division) return `/books/${book}/${division}/${chapter}`;
|
||||||
|
return `/books/${book}/${chapter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a BFF verse response (snake_case) to the frontend VerseRecord (camelCase).
|
||||||
|
*/
|
||||||
|
function mapVerse(v: Record<string, unknown>): VerseRecord {
|
||||||
|
const bookTitle = String(v.book_title ?? v.bookTitle ?? "");
|
||||||
|
const chapter = Number(v.chapter ?? 0);
|
||||||
|
const verse = Number(v.verse ?? 0);
|
||||||
|
return {
|
||||||
|
id: String(v.id ?? ""),
|
||||||
|
book: String(v.book ?? ""),
|
||||||
|
division: v.division ? String(v.division) : null,
|
||||||
|
chapter,
|
||||||
|
verse,
|
||||||
|
verseEnd:
|
||||||
|
v.verse_end != null
|
||||||
|
? Number(v.verse_end)
|
||||||
|
: v.verseEnd != null
|
||||||
|
? Number(v.verseEnd)
|
||||||
|
: null,
|
||||||
|
reference: String(v.reference ?? ""),
|
||||||
|
bookTitle,
|
||||||
|
devanagari: String(v.devanagari ?? ""),
|
||||||
|
bengali: v.bengali ? String(v.bengali) : null,
|
||||||
|
transliteration: String(v.transliteration ?? ""),
|
||||||
|
synonyms: (v.synonyms as { word: string; meanings: string[] }[]) ?? [],
|
||||||
|
translation: String(v.translation ?? ""),
|
||||||
|
purport: (v.purport as string[]) ?? [],
|
||||||
|
// Aliases for page backwards compatibility
|
||||||
|
title: bookTitle,
|
||||||
|
text: verse,
|
||||||
|
mantra: chapter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a BFF verse-index entry (snake_case) to VerseIndexEntry (camelCase).
|
||||||
|
*/
|
||||||
|
function mapVerseIndex(v: Record<string, unknown>): VerseIndexEntry {
|
||||||
|
return {
|
||||||
|
verse: Number(v.verse ?? 0),
|
||||||
|
verseEnd: v.verse_end ? Number(v.verse_end) : null,
|
||||||
|
reference: String(v.reference ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Book meta
|
// Book meta
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all book metas — each has { book, ...data } where data is the intro.json blob.
|
* Get all book metas — each has { book, ...data } where data is the intro.json blob.
|
||||||
|
*
|
||||||
|
* The BFF returns { id, book, data: { title, author, chapters, ... } }.
|
||||||
|
* We flatten it to { book, title, author, chapters, ... } for the frontend.
|
||||||
*/
|
*/
|
||||||
export async function getAllBooks(): Promise<BookMeta[]> {
|
export async function getAllBooks(): Promise<BookMeta[]> {
|
||||||
const metas = await prisma.bookMeta.findMany();
|
const raw = await bffList<{ book: string; data: Record<string, unknown> }>(
|
||||||
return metas.map((m) => ({
|
"/books",
|
||||||
book: m.book,
|
{ tags: ["books"] },
|
||||||
...(m.data as Record<string, unknown>),
|
);
|
||||||
}));
|
return raw.map(flattenBookMeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single book's meta by slug ("bg", "sb", "cc").
|
* Get a single book's meta by slug ("bg", "sb", "cc").
|
||||||
*/
|
*/
|
||||||
export async function getBookMeta(book: string): Promise<BookMeta | null> {
|
export async function getBookMeta(book: string): Promise<BookMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book } });
|
const raw = await bff<{ book: string; data: Record<string, unknown> }>(
|
||||||
if (!meta) return null;
|
`/books/${book}`,
|
||||||
return { book: meta.book, ...(meta.data as Record<string, unknown>) };
|
{ tags: [`book-${book}`] },
|
||||||
|
);
|
||||||
|
if (!raw) return null;
|
||||||
|
return flattenBookMeta(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a BFF book meta response.
|
||||||
|
* BFF returns: { id, book, data: { title, author, chapters, ... } }
|
||||||
|
* Frontend expects: { book, title, author, chapters, ... }
|
||||||
|
*/
|
||||||
|
function flattenBookMeta(raw: {
|
||||||
|
book: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}): BookMeta {
|
||||||
|
const data = raw.data && typeof raw.data === "object" ? raw.data : {};
|
||||||
|
return { book: raw.book, ...data } as BookMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -115,17 +224,14 @@ export async function getChapterVerseIndex(
|
|||||||
division: string | null,
|
division: string | null,
|
||||||
chapter: number,
|
chapter: number,
|
||||||
): Promise<VerseIndexEntry[]> {
|
): Promise<VerseIndexEntry[]> {
|
||||||
const entries = await prisma.verse.findMany({
|
const path = versePath(book, division, chapter);
|
||||||
where: { book, division, chapter },
|
const res = await bff<{
|
||||||
orderBy: { verse: "asc" },
|
verses?: Record<string, unknown>[];
|
||||||
select: {
|
}>(path, {
|
||||||
verse: true,
|
tags: [`verses-${book}`],
|
||||||
verseEnd: true,
|
|
||||||
reference: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!res || !res.verses) return [];
|
||||||
return entries;
|
return res.verses.map(mapVerseIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -147,27 +253,41 @@ export async function getChapterVerses(
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = DEFAULT_PAGE_SIZE,
|
limit: number = DEFAULT_PAGE_SIZE,
|
||||||
): Promise<PaginatedVerses> {
|
): Promise<PaginatedVerses> {
|
||||||
const skip = (page - 1) * limit;
|
const path = versePath(book, division, chapter);
|
||||||
|
|
||||||
const where = { book, division, chapter };
|
const raw = await bff<{
|
||||||
|
verses: Record<string, unknown>[];
|
||||||
const [verses, total] = await Promise.all([
|
total: number;
|
||||||
prisma.verse.findMany({
|
page: number;
|
||||||
where,
|
limit: number;
|
||||||
orderBy: { verse: "asc" },
|
totalPages: number;
|
||||||
skip,
|
hasMore: boolean;
|
||||||
take: limit,
|
}>(`${path}/verses`, {
|
||||||
}),
|
params: {
|
||||||
prisma.verse.count({ where }),
|
page: String(page),
|
||||||
]);
|
limit: String(limit),
|
||||||
|
},
|
||||||
|
tags: [`verses-${book}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
return {
|
return {
|
||||||
verses: verses as unknown as VerseRecord[],
|
verses: [],
|
||||||
total,
|
total: 0,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
totalPages: Math.ceil(total / limit),
|
totalPages: 0,
|
||||||
hasMore: skip + verses.length < total,
|
hasMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
verses: (raw.verses ?? []).map(mapVerse),
|
||||||
|
total: raw.total,
|
||||||
|
page: raw.page,
|
||||||
|
limit: raw.limit,
|
||||||
|
totalPages: raw.totalPages,
|
||||||
|
hasMore: raw.hasMore,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,11 +304,11 @@ export async function getSingleVerse(
|
|||||||
chapter: number,
|
chapter: number,
|
||||||
verse: number,
|
verse: number,
|
||||||
): Promise<VerseRecord | null> {
|
): Promise<VerseRecord | null> {
|
||||||
const result = await prisma.verse.findFirst({
|
const path = versePath(book, division, chapter);
|
||||||
where: { book, division, chapter, verse },
|
const raw = await bff<Record<string, unknown>>(`${path}/${verse}`, {
|
||||||
|
tags: [`verses-${book}`],
|
||||||
});
|
});
|
||||||
|
return raw ? mapVerse(raw) : null;
|
||||||
return result as unknown as VerseRecord | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -199,109 +319,76 @@ export async function getSingleVerse(
|
|||||||
* Get all prose chapters/segments for a given book and volume,
|
* Get all prose chapters/segments for a given book and volume,
|
||||||
* optionally filtered by type ("chapter" | "intro-segment").
|
* optionally filtered by type ("chapter" | "intro-segment").
|
||||||
* Ordered by the `order` field.
|
* Ordered by the `order` field.
|
||||||
|
*
|
||||||
|
* For KB (which has real volumes), uses /api/v1/books/kb/{volume}.
|
||||||
|
* For other books (volume=0 sentinel), uses /api/v1/books/{code}/chapters.
|
||||||
*/
|
*/
|
||||||
export async function getProseChapters(
|
export async function getProseChapters(
|
||||||
book: string,
|
book: string,
|
||||||
volume: number,
|
volume: number,
|
||||||
type?: string,
|
type?: string,
|
||||||
): Promise<ProseChapterRecord[]> {
|
): Promise<ProseChapterRecord[]> {
|
||||||
const where: Record<string, unknown> = { book, volume };
|
// KB has real volumes (1, 2, 3, …); other prose books use volume=0 as sentinel
|
||||||
if (type) where.type = type;
|
const path =
|
||||||
|
book === "kb" && volume > 0
|
||||||
|
? `/books/kb/${volume}`
|
||||||
|
: `/books/${book}/chapters`;
|
||||||
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
const params: Record<string, string> = {};
|
||||||
where,
|
if (type) params.type = type;
|
||||||
orderBy: { order: "asc" },
|
|
||||||
|
const chapters = await bffList<ProseChapterRecord>(path, {
|
||||||
|
params: Object.keys(params).length > 0 ? params : undefined,
|
||||||
|
tags: [`book-${book}`],
|
||||||
});
|
});
|
||||||
|
return chapters.map((ch) => ({
|
||||||
return results.map(normalizeProse);
|
...ch,
|
||||||
|
paragraphs: sanitizeParagraphs(ch.paragraphs),
|
||||||
|
id: ch.id ?? "",
|
||||||
|
book: ch.book ?? book,
|
||||||
|
volume: ch.volume ?? volume,
|
||||||
|
chapter: ch.chapter ?? null,
|
||||||
|
title: ch.title ?? "",
|
||||||
|
type: ch.type ?? "chapter",
|
||||||
|
slug: ch.slug ?? "",
|
||||||
|
order: ch.order ?? 0,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single prose chapter by book + volume + slug.
|
* Get a single prose chapter by book + volume + slug.
|
||||||
|
*
|
||||||
|
* For KB, uses /api/v1/books/kb/{volume}/{slug}.
|
||||||
|
* For other books, uses /api/v1/books/{code}/chapters/{slug}.
|
||||||
*/
|
*/
|
||||||
export async function getProseChapter(
|
export async function getProseChapter(
|
||||||
book: string,
|
book: string,
|
||||||
volume: number,
|
volume: number,
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<ProseChapterRecord | null> {
|
): Promise<ProseChapterRecord | null> {
|
||||||
const result = await prisma.proseChapter.findUnique({
|
const path =
|
||||||
where: {
|
book === "kb" && volume > 0
|
||||||
book_volume_slug: { book, volume, slug },
|
? `/books/kb/${volume}/${slug}`
|
||||||
},
|
: `/books/${book}/chapters/${slug}`;
|
||||||
|
|
||||||
|
const chapter = await bff<ProseChapterRecord>(path, {
|
||||||
|
tags: [`book-${book}`],
|
||||||
});
|
});
|
||||||
|
if (!chapter) return null;
|
||||||
return result ? normalizeProse(result) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a prose chapter from the DB.
|
|
||||||
* Some chapters were stored with paragraphs as a JSON string (fallback
|
|
||||||
* for BSON wire-protocol corruption). This normalizes them back to arrays.
|
|
||||||
*/
|
|
||||||
function normalizeProse(doc: {
|
|
||||||
id: string;
|
|
||||||
book: string;
|
|
||||||
volume: number;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[] | JsonValue;
|
|
||||||
}): ProseChapterRecord {
|
|
||||||
let paragraphs: string[];
|
|
||||||
|
|
||||||
if (typeof doc.paragraphs === "string") {
|
|
||||||
// Was stored as stringified JSON — parse it back
|
|
||||||
try {
|
|
||||||
paragraphs = JSON.parse(doc.paragraphs);
|
|
||||||
} catch {
|
|
||||||
paragraphs = [doc.paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(doc.paragraphs)) {
|
|
||||||
paragraphs = doc.paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
paragraphs = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize HTML — strip dangerous tags and event handlers
|
|
||||||
paragraphs = paragraphs.map(sanitizeHtml);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
...chapter,
|
||||||
book: doc.book,
|
paragraphs: sanitizeParagraphs(chapter.paragraphs),
|
||||||
volume: doc.volume,
|
id: chapter.id ?? "",
|
||||||
chapter: doc.chapter,
|
book: chapter.book ?? book,
|
||||||
title: doc.title,
|
volume: chapter.volume ?? volume,
|
||||||
type: doc.type,
|
chapter: chapter.chapter ?? null,
|
||||||
slug: doc.slug,
|
title: chapter.title ?? "",
|
||||||
order: doc.order,
|
type: chapter.type ?? "chapter",
|
||||||
paragraphs,
|
slug: chapter.slug ?? slug,
|
||||||
|
order: chapter.order ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize an HTML string by stripping dangerous tags and attributes.
|
|
||||||
* Keeps safe formatting tags: <i>, <b>, <em>, <strong>, <br>, <div>, <span>.
|
|
||||||
*/
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
// Remove <script>...</script>, <style>...</style>, <iframe>...</iframe>
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
// Remove lone <script>, <style>, <iframe> opening/closing tags
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
// Remove event handler attributes (onclick, onload, onerror, etc.)
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
// Remove javascript: URLs
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Glossary
|
// Glossary
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -310,9 +397,5 @@ function sanitizeHtml(html: string): string {
|
|||||||
* Get all glossary entries, ordered alphabetically by term.
|
* Get all glossary entries, ordered alphabetically by term.
|
||||||
*/
|
*/
|
||||||
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
|
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
|
||||||
const entries = await prisma.glossaryEntry.findMany({
|
return bffList<GlossaryRecord>("/glossary", { tags: ["glossary"] });
|
||||||
orderBy: { term: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return entries as unknown as GlossaryRecord[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-249
@@ -1,280 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Beyond Birth and Death (BBD).
|
* Data access layer for Beyond Birth and Death (BBD).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* BBD is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "bbd";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "bbd";
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
|
export type BbdMeta = BookMeta;
|
||||||
|
export type BbdChapterEntry = { number: number; title: string };
|
||||||
|
export type BbdIntroSegment = IntroSegment;
|
||||||
|
export type BbdChapter = Chapter;
|
||||||
|
export type BbdSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type BbdMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: BbdChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BbdChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BbdIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BbdChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type BbdSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getBbdMeta(): Promise<BbdMeta | null> {
|
export async function getBbdMeta(): Promise<BbdMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
return getBookMeta(CODE);
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as BbdMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getBbdIntroSegments(): Promise<BbdIntroSegment[]> {
|
export async function getBbdIntroSegments(): Promise<BbdIntroSegment[]> {
|
||||||
const results = await prisma.proseChapter.findMany({
|
return getIntroSegments(CODE);
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getBbdIntroSegment(
|
export async function getBbdIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<BbdIntroSegment | null> {
|
): Promise<BbdIntroSegment | null> {
|
||||||
// DB stores with "intro-" prefix; accept either form
|
return getIntroSegment(CODE, slug);
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–5).
|
|
||||||
*/
|
|
||||||
export async function getBbdChapter(
|
export async function getBbdChapter(
|
||||||
chapterNum: number,
|
chapterNum: number,
|
||||||
): Promise<BbdChapter | null> {
|
): Promise<BbdChapter | null> {
|
||||||
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) {
|
return getChapter(CODE, String(chapterNum));
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "5" → chapter
|
|
||||||
*/
|
|
||||||
export async function getBbdContent(
|
export async function getBbdContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<BbdChapter | BbdIntroSegment | null> {
|
): Promise<BbdChapter | BbdIntroSegment | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
return getContent(CODE, slug);
|
||||||
return getBbdIntroSegment(slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
|
||||||
return getBbdChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–5
|
|
||||||
*/
|
|
||||||
export async function getBbdSidebarItems(): Promise<BbdSidebarItem[]> {
|
export async function getBbdSidebarItems(): Promise<BbdSidebarItem[]> {
|
||||||
const [meta, allDocs] = await Promise.all([
|
return getSidebarItems(CODE);
|
||||||
getBbdMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: BbdSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–5 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* 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 ?? [];
|
||||||
|
}
|
||||||
@@ -1,299 +1,29 @@
|
|||||||
/**
|
import {
|
||||||
* Data access layer for Bhagavāner Kathā (BK).
|
getBookMeta,
|
||||||
*
|
getChapter,
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
getContent,
|
||||||
* BK is a simple prose book with no volume structure and no intro segments —
|
getSidebarItems,
|
||||||
* just 2 chapters directly. We use volume=0 as the sentinel for "no volumes".
|
type BookMeta,
|
||||||
*
|
type Chapter,
|
||||||
* When the DB isn't seeded yet, falls back to reading local JSON files
|
type SidebarItem,
|
||||||
* from lib/jsons/books/bk/.
|
} from "@/lib/books";
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
const CODE = "bk";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export type BkMeta = BookMeta;
|
||||||
// Constants
|
export type BkChapterEntry = { number: number; title: string };
|
||||||
// ---------------------------------------------------------------------------
|
export type BkChapter = Chapter;
|
||||||
|
export type BkSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getBkMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
const BOOK = "bk";
|
|
||||||
|
|
||||||
const BK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "bk");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type BkMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number | null;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: BkChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BkChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BkChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type BkSidebarItem = {
|
|
||||||
/** URL-safe slug: "1", "2" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/api.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getBkChapter(chapterNum: number) {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getChapter(CODE, String(chapterNum));
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getBkContent(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getContent(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getBkSidebarItems() {
|
||||||
return result.map(sanitizeHtml);
|
return getSidebarItems(CODE);
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): BkChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
BK_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<BkChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getBkMeta(): Promise<BkMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as BkMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(BK_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<BkMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–2).
|
|
||||||
*/
|
|
||||||
export async function getBkChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<BkChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 2 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalChapter(chapterNum);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "1" … "2" → chapter
|
|
||||||
*/
|
|
||||||
export async function getBkContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<BkChapter | null> {
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
|
|
||||||
return getBkChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: chapters 1–2 (no intro segments)
|
|
||||||
*/
|
|
||||||
export async function getBkSidebarItems(): Promise<BkSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getBkMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
title: true,
|
|
||||||
type: true,
|
|
||||||
chapter: true,
|
|
||||||
order: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If we got DB results, build from those
|
|
||||||
if (meta && allDocs.length > 0) {
|
|
||||||
return buildSidebarFromDocs(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: build from local JSON
|
|
||||||
return buildSidebarFromLocal(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromDocs(meta: BkMeta): BkSidebarItem[] {
|
|
||||||
const items: BkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Chapters 1–2 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: BkMeta | null): BkSidebarItem[] {
|
|
||||||
const items: BkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Chapters from meta or scan local chapter JSONs
|
|
||||||
if (meta && meta.chapters) {
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: scan local chapter JSONs
|
|
||||||
const chaptersDir = path.join(BK_JSON_DIR, "chapters");
|
|
||||||
if (fs.existsSync(chaptersDir)) {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(chaptersDir)
|
|
||||||
.filter((f) => f.endsWith(".json"))
|
|
||||||
.sort();
|
|
||||||
for (const file of files) {
|
|
||||||
const ch = readLocalJson<BkChapter>(path.join(chaptersDir, file));
|
|
||||||
if (ch) {
|
|
||||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
|
||||||
items.push({
|
|
||||||
slug: String(num),
|
|
||||||
label: `Chapter ${num}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: num,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+368
@@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapters — if parts exist, group chapters under part headers
|
||||||
|
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).
|
* Data access layer for Śrī Brahma-saṁhitā (BS).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer (BFF API) for prose content,
|
||||||
* BS has BOTH verses (in the `verses` collection)
|
* and to lib/api.ts for verse lookups.
|
||||||
* AND prose intro segments (in the `prose_chapters` collection).
|
|
||||||
*
|
*
|
||||||
* Verses were seeded with:
|
* BS has 62 texts (verses) plus intro segments.
|
||||||
* - book: "bs", division: null
|
* Verses are stored with: book "bs", division null, chapter 5, verse = text number.
|
||||||
* - chapter = text number (1-62)
|
|
||||||
* - verse = always 1
|
|
||||||
*
|
|
||||||
* Intro segments were seeded with:
|
|
||||||
* - book: "bs", volume: 0, type: "intro-segment"
|
|
||||||
* - slugs like "intro-introduction", "intro-foreword"
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
import { getSingleVerse, type VerseRecord } from "@/lib/api";
|
||||||
|
|
||||||
|
const CODE = "bs";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const BOOK = "bs";
|
export type BsMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type BsIntroSegment = IntroSegment;
|
||||||
|
export type BsChapter = Chapter;
|
||||||
|
export type BsSidebarItem = SidebarItem;
|
||||||
|
export type BsVerse = VerseRecord;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Text title lookup
|
// Data accessors
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const TEXT_TITLES: Record<number, string> = {
|
export async function getBsMeta() {
|
||||||
1: "Text 1",
|
return getBookMeta(CODE);
|
||||||
2: "Text 2",
|
}
|
||||||
3: "Text 3",
|
export async function getBsIntroSegments() {
|
||||||
4: "Text 4",
|
return getIntroSegments(CODE);
|
||||||
5: "Text 5",
|
}
|
||||||
6: "Text 6",
|
export async function getBsIntroSegment(slug: string) {
|
||||||
7: "Text 7",
|
return getIntroSegment(CODE, slug);
|
||||||
8: "Text 8",
|
|
||||||
9: "Text 9",
|
|
||||||
10: "Text 10",
|
|
||||||
11: "Text 11",
|
|
||||||
12: "Text 12",
|
|
||||||
13: "Text 13",
|
|
||||||
14: "Text 14",
|
|
||||||
15: "Text 15",
|
|
||||||
16: "Text 16",
|
|
||||||
17: "Text 17",
|
|
||||||
18: "Text 18",
|
|
||||||
19: "Text 19",
|
|
||||||
20: "Text 20",
|
|
||||||
21: "Text 21",
|
|
||||||
22: "Text 22",
|
|
||||||
23: "Text 23",
|
|
||||||
24: "Text 24",
|
|
||||||
25: "Text 25",
|
|
||||||
26: "Text 26",
|
|
||||||
27: "Text 27",
|
|
||||||
28: "Text 28",
|
|
||||||
29: "Text 29",
|
|
||||||
30: "Text 30",
|
|
||||||
31: "Text 31",
|
|
||||||
32: "Text 32",
|
|
||||||
33: "Text 33",
|
|
||||||
34: "Text 34",
|
|
||||||
35: "Text 35",
|
|
||||||
36: "Text 36",
|
|
||||||
37: "Text 37",
|
|
||||||
38: "Text 38",
|
|
||||||
39: "Text 39",
|
|
||||||
40: "Text 40",
|
|
||||||
41: "Text 41",
|
|
||||||
42: "Text 42",
|
|
||||||
43: "Text 43",
|
|
||||||
44: "Text 44",
|
|
||||||
45: "Text 45",
|
|
||||||
46: "Text 46",
|
|
||||||
47: "Text 47",
|
|
||||||
48: "Text 48",
|
|
||||||
49: "Text 49",
|
|
||||||
50: "Text 50",
|
|
||||||
51: "Text 51",
|
|
||||||
52: "Text 52",
|
|
||||||
53: "Text 53",
|
|
||||||
54: "Text 54",
|
|
||||||
55: "Text 55",
|
|
||||||
56: "Text 56",
|
|
||||||
57: "Text 57",
|
|
||||||
58: "Text 58",
|
|
||||||
59: "Text 59",
|
|
||||||
60: "Text 60",
|
|
||||||
61: "Text 61",
|
|
||||||
62: "Text 62",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type BsMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalTexts: number;
|
|
||||||
texts: BsTextEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BsTextEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BsIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BsVerse = {
|
|
||||||
book: string;
|
|
||||||
book_title: string;
|
|
||||||
text: number; // 1-62
|
|
||||||
title: string;
|
|
||||||
reference: string;
|
|
||||||
devanagari: string;
|
|
||||||
transliteration: string;
|
|
||||||
synonyms: { word: string; meanings: string[] }[];
|
|
||||||
translation: string;
|
|
||||||
purport: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type BsSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-introduction", "intro-foreword", "1" … "62" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "verse" */
|
|
||||||
type: string;
|
|
||||||
/** Text number for verses, or null for intro segments */
|
|
||||||
text: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/iso.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
|
|
||||||
function normalizePurport(purport: string[] | JsonValue): string[] {
|
|
||||||
return normalizeParagraphs(purport);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, texts list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getBsMeta(): Promise<BsMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as BsMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments (from prose_chapters collection)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getBsIntroSegments(): Promise<BsIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getBsIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<BsIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Verses (from verses collection)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single verse/text by slug.
|
|
||||||
*
|
|
||||||
* - "1"-"62" → chapter=1–62, verse=1
|
|
||||||
*
|
|
||||||
* Verse stored as chapter=text_number, verse=1 in the verses collection.
|
|
||||||
*/
|
|
||||||
export async function getBsVerse(slug: string): Promise<BsVerse | null> {
|
export async function getBsVerse(slug: string): Promise<BsVerse | null> {
|
||||||
const num = Number(slug);
|
const num = parseInt(slug, 10);
|
||||||
if (!Number.isInteger(num) || num < 1 || num > 62) return null;
|
if (isNaN(num)) return null;
|
||||||
const textNum = num;
|
return getSingleVerse(CODE, null, 5, num);
|
||||||
|
|
||||||
const doc = await prisma.verse.findFirst({
|
|
||||||
where: { book: BOOK, division: null, chapter: textNum, verse: 1 },
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
book_title: doc.bookTitle,
|
|
||||||
text: doc.chapter, // chapter field stores the text number
|
|
||||||
title: TEXT_TITLES[doc.chapter] ?? `Text ${doc.chapter}`,
|
|
||||||
reference: doc.reference,
|
|
||||||
devanagari: doc.devanagari,
|
|
||||||
transliteration: doc.transliteration,
|
|
||||||
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
|
|
||||||
translation: sanitizeHtml(doc.translation),
|
|
||||||
purport: normalizePurport(doc.purport),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "intro-foreword" → intro segment
|
|
||||||
* "1" … "62" → verse (text)
|
|
||||||
*/
|
|
||||||
export async function getBsContent(
|
export async function getBsContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<BsVerse | BsIntroSegment | null> {
|
): Promise<BsVerse | BsIntroSegment | BsChapter | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
// Try as verse first (numeric slug)
|
||||||
return getBsIntroSegment(slug);
|
if (/^\d+$/.test(slug)) {
|
||||||
|
const verse = await getBsVerse(slug);
|
||||||
|
if (verse) return verse;
|
||||||
}
|
}
|
||||||
|
// Fall back to prose content (intro segments, etc.)
|
||||||
const num = Number(slug);
|
return getContent(CODE, slug);
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 62) {
|
|
||||||
return getBsVerse(String(num));
|
|
||||||
}
|
}
|
||||||
|
export async function getBsSidebarItems() {
|
||||||
return null;
|
return getSidebarItems(CODE);
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: Introduction → Foreword → Texts 1-62
|
|
||||||
*/
|
|
||||||
export async function getBsSidebarItems(): Promise<BsSidebarItem[]> {
|
|
||||||
const items: BsSidebarItem[] = [];
|
|
||||||
|
|
||||||
// 1. Introduction intro segment
|
|
||||||
items.push({
|
|
||||||
slug: "intro-introduction",
|
|
||||||
label: "Introduction",
|
|
||||||
type: "intro-segment",
|
|
||||||
text: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Foreword intro segment
|
|
||||||
items.push({
|
|
||||||
slug: "intro-foreword",
|
|
||||||
label: "Foreword",
|
|
||||||
type: "intro-segment",
|
|
||||||
text: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Texts 1-62 (verses)
|
|
||||||
for (let i = 1; i <= 62; i++) {
|
|
||||||
items.push({
|
|
||||||
slug: String(i),
|
|
||||||
label: `${i}. ${TEXT_TITLES[i]}`,
|
|
||||||
type: "verse",
|
|
||||||
text: i,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-292
@@ -1,299 +1,29 @@
|
|||||||
/**
|
import {
|
||||||
* Data access layer for Śrī Caitanya-bhāgavata Commentary (CBHC).
|
getBookMeta,
|
||||||
*
|
getChapter,
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
getContent,
|
||||||
* CBHC is a simple prose book with no volume structure and no intro segments —
|
getSidebarItems,
|
||||||
* just 4 chapters directly. We use volume=0 as the sentinel for "no volumes".
|
type BookMeta,
|
||||||
*
|
type Chapter,
|
||||||
* When the DB isn't seeded yet, falls back to reading local JSON files
|
type SidebarItem,
|
||||||
* from lib/jsons/books/cbhc/.
|
} from "@/lib/books";
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
const CODE = "cbhc";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export type CbhcMeta = BookMeta;
|
||||||
// Constants
|
export type CbhcChapterEntry = { number: number; title: string };
|
||||||
// ---------------------------------------------------------------------------
|
export type CbhcChapter = Chapter;
|
||||||
|
export type CbhcSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getCbhcMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
const BOOK = "cbhc";
|
|
||||||
|
|
||||||
const CBHC_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "cbhc");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type CbhcMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number | null;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: CbhcChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CbhcChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CbhcChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type CbhcSidebarItem = {
|
|
||||||
/** URL-safe slug: "1", "2", "3", "4" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/api.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getCbhcChapter(chapterNum: number) {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getChapter(CODE, String(chapterNum));
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getCbhcContent(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getContent(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getCbhcSidebarItems() {
|
||||||
return result.map(sanitizeHtml);
|
return getSidebarItems(CODE);
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): CbhcChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
CBHC_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<CbhcChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getCbhcMeta(): Promise<CbhcMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as CbhcMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(CBHC_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<CbhcMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–4).
|
|
||||||
*/
|
|
||||||
export async function getCbhcChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<CbhcChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalChapter(chapterNum);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "1" … "4" → chapter
|
|
||||||
*/
|
|
||||||
export async function getCbhcContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<CbhcChapter | null> {
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
|
|
||||||
return getCbhcChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: chapters 1–4 (no intro segments)
|
|
||||||
*/
|
|
||||||
export async function getCbhcSidebarItems(): Promise<CbhcSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getCbhcMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
title: true,
|
|
||||||
type: true,
|
|
||||||
chapter: true,
|
|
||||||
order: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If we got DB results, build from those
|
|
||||||
if (meta && allDocs.length > 0) {
|
|
||||||
return buildSidebarFromDocs(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: build from local JSON
|
|
||||||
return buildSidebarFromLocal(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromDocs(meta: CbhcMeta): CbhcSidebarItem[] {
|
|
||||||
const items: CbhcSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Chapters 1–4 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: CbhcMeta | null): CbhcSidebarItem[] {
|
|
||||||
const items: CbhcSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Chapters from meta or scan local chapter JSONs
|
|
||||||
if (meta && meta.chapters) {
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: scan local chapter JSONs
|
|
||||||
const chaptersDir = path.join(CBHC_JSON_DIR, "chapters");
|
|
||||||
if (fs.existsSync(chaptersDir)) {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(chaptersDir)
|
|
||||||
.filter((f) => f.endsWith(".json"))
|
|
||||||
.sort();
|
|
||||||
for (const file of files) {
|
|
||||||
const ch = readLocalJson<CbhcChapter>(path.join(chaptersDir, file));
|
|
||||||
if (ch) {
|
|
||||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
|
||||||
items.push({
|
|
||||||
slug: String(num),
|
|
||||||
label: `Chapter ${num}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: num,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* Data access layer for Disciples.
|
||||||
*
|
*
|
||||||
* Provides queries for browsing disciples by letter, place, year, and search.
|
* Provides queries for browsing disciples by letter, place, year, and search.
|
||||||
* Uses the Prisma Disciple model (collection: disciples).
|
* Uses the BFF API instead of direct Prisma queries.
|
||||||
*
|
*
|
||||||
* Disciples have an initiatedName and suffix (e.g., "Dasa", "Devi Dasi", "Sw").
|
* Disciples have an initiatedName and suffix (e.g., "Dasa", "Devi Dasi", "Sw").
|
||||||
* The letter index aggregates disciples by the first letter of their initiatedName.
|
* The letter index aggregates disciples by the first letter of their initiatedName.
|
||||||
* Place and year groupings allow browsing by initiation location and year.
|
* Place and year groupings allow browsing by initiation location and year.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { bff, bffList } from "@/lib/bff";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
|
||||||
@@ -37,26 +37,51 @@ export type YearGroup = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Summary select clause (reused across queries) ---
|
|
||||||
|
|
||||||
const discipleSummarySelect = {
|
|
||||||
id: true,
|
|
||||||
initiatedName: true,
|
|
||||||
suffix: true,
|
|
||||||
initiationDate: true,
|
|
||||||
initiationPlace: true,
|
|
||||||
isVerified: true,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// --- Suffix filter type ---
|
// --- Suffix filter type ---
|
||||||
|
|
||||||
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
|
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
|
||||||
|
|
||||||
|
// --- BFF response types (snake_case from Go API) ---
|
||||||
|
|
||||||
|
type BffDisciple = {
|
||||||
|
id: string;
|
||||||
|
initiated_name: string;
|
||||||
|
suffix: string;
|
||||||
|
initiation_date: string | null;
|
||||||
|
initiation_place: string | null;
|
||||||
|
karmi_name: string | null;
|
||||||
|
is_verified: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BffLetterCount = {
|
||||||
|
_id: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BffSuffixCount = {
|
||||||
|
_id: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Field mapping helper ---
|
||||||
|
|
||||||
|
function mapDisciple(d: BffDisciple): DiscipleSummary {
|
||||||
|
return {
|
||||||
|
id: d.id,
|
||||||
|
initiatedName: d.initiated_name,
|
||||||
|
suffix: d.suffix,
|
||||||
|
initiationDate: d.initiation_date ? new Date(d.initiation_date) : null,
|
||||||
|
initiationPlace: d.initiation_place ?? null,
|
||||||
|
isVerified: d.is_verified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// --- Count ---
|
// --- Count ---
|
||||||
|
|
||||||
/** Get total number of disciples in the collection. */
|
/** Get total number of disciples in the collection. */
|
||||||
export async function getTotalDiscipleCount(): Promise<number> {
|
export async function getTotalDiscipleCount(): Promise<number> {
|
||||||
return prisma.disciple.count();
|
const result = await bff<{ total: number }>("/disciples/count");
|
||||||
|
return result?.total ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get count of disciples matching optional letter + suffix filters. */
|
/** Get count of disciples matching optional letter + suffix filters. */
|
||||||
@@ -64,14 +89,19 @@ export async function getFilteredDiscipleCount(
|
|||||||
letter: string | null,
|
letter: string | null,
|
||||||
suffix: SuffixFilter = "all",
|
suffix: SuffixFilter = "all",
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const where: Record<string, unknown> = {};
|
const params: Record<string, string> = {};
|
||||||
if (letter) {
|
if (letter) params.letter = letter;
|
||||||
where.initiatedName = { startsWith: letter, mode: "insensitive" };
|
if (suffix !== "all") params.suffix = suffix;
|
||||||
|
|
||||||
|
// If no filters, just use total count endpoint
|
||||||
|
if (!letter && suffix === "all") {
|
||||||
|
return getTotalDiscipleCount();
|
||||||
}
|
}
|
||||||
if (suffix !== "all") {
|
|
||||||
where.suffix = suffix;
|
// Fetch filtered list and count client-side
|
||||||
}
|
// TODO: Consider adding a dedicated BFF count endpoint with filter params for better performance
|
||||||
return prisma.disciple.count({ where });
|
const disciples = await bffList<BffDisciple>("/disciples", { params });
|
||||||
|
return disciples.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Letter-based queries ---
|
// --- Letter-based queries ---
|
||||||
@@ -79,23 +109,16 @@ export async function getFilteredDiscipleCount(
|
|||||||
/**
|
/**
|
||||||
* Get all disciples whose initiatedName starts with the given letter.
|
* Get all disciples whose initiatedName starts with the given letter.
|
||||||
*
|
*
|
||||||
* Uses Prisma's `startsWith` filter with case-insensitive mode.
|
* Uses BFF endpoint with letter query param.
|
||||||
* Results are ordered by initiatedName ascending.
|
* Results are ordered by initiatedName ascending (handled by BFF).
|
||||||
*/
|
*/
|
||||||
export async function getDisciplesByLetter(
|
export async function getDisciplesByLetter(
|
||||||
letter: string,
|
letter: string,
|
||||||
): Promise<DiscipleSummary[]> {
|
): Promise<DiscipleSummary[]> {
|
||||||
const disciples = await prisma.disciple.findMany({
|
const disciples = await bffList<BffDisciple>("/disciples", {
|
||||||
where: {
|
params: { letter },
|
||||||
initiatedName: {
|
|
||||||
startsWith: letter,
|
|
||||||
mode: "insensitive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { initiatedName: "asc" },
|
|
||||||
select: discipleSummarySelect,
|
|
||||||
});
|
});
|
||||||
return disciples;
|
return disciples.map(mapDisciple);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,16 +130,11 @@ export async function getDisciplesByLetter(
|
|||||||
export async function getAllDisciples(
|
export async function getAllDisciples(
|
||||||
suffix: SuffixFilter = "all",
|
suffix: SuffixFilter = "all",
|
||||||
): Promise<DiscipleSummary[]> {
|
): Promise<DiscipleSummary[]> {
|
||||||
const where: Record<string, unknown> = {};
|
const params: Record<string, string> = {};
|
||||||
if (suffix !== "all") {
|
if (suffix !== "all") params.suffix = suffix;
|
||||||
where.suffix = suffix;
|
|
||||||
}
|
const disciples = await bffList<BffDisciple>("/disciples", { params });
|
||||||
const disciples = await prisma.disciple.findMany({
|
return disciples.map(mapDisciple);
|
||||||
where,
|
|
||||||
orderBy: { initiatedName: "asc" },
|
|
||||||
select: discipleSummarySelect,
|
|
||||||
});
|
|
||||||
return disciples;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,19 +151,12 @@ export async function getFilteredDisciples(
|
|||||||
letter: string | null,
|
letter: string | null,
|
||||||
suffix: SuffixFilter = "all",
|
suffix: SuffixFilter = "all",
|
||||||
): Promise<DiscipleSummary[]> {
|
): Promise<DiscipleSummary[]> {
|
||||||
const where: Record<string, unknown> = {};
|
const params: Record<string, string> = {};
|
||||||
if (letter) {
|
if (letter) params.letter = letter;
|
||||||
where.initiatedName = { startsWith: letter, mode: "insensitive" };
|
if (suffix !== "all") params.suffix = suffix;
|
||||||
}
|
|
||||||
if (suffix !== "all") {
|
const disciples = await bffList<BffDisciple>("/disciples", { params });
|
||||||
where.suffix = suffix;
|
return disciples.map(mapDisciple);
|
||||||
}
|
|
||||||
const disciples = await prisma.disciple.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { initiatedName: "asc" },
|
|
||||||
select: discipleSummarySelect,
|
|
||||||
});
|
|
||||||
return disciples;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,42 +167,22 @@ export async function getFilteredDisciples(
|
|||||||
export async function getDiscipleSuffixCounts(): Promise<
|
export async function getDiscipleSuffixCounts(): Promise<
|
||||||
{ suffix: string; count: number }[]
|
{ suffix: string; count: number }[]
|
||||||
> {
|
> {
|
||||||
const disciples = await prisma.disciple.findMany({
|
const suffixes = await bffList<BffSuffixCount>("/disciples/suffixes");
|
||||||
select: { suffix: true },
|
return suffixes
|
||||||
});
|
.map((s) => ({ suffix: s._id, count: s.count }))
|
||||||
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const d of disciples) {
|
|
||||||
counts[d.suffix] = (counts[d.suffix] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(counts)
|
|
||||||
.map(([suffix, count]) => ({ suffix, count }))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get counts of disciples grouped by the first letter of their initiatedName.
|
* Get counts of disciples grouped by the first letter of their initiatedName.
|
||||||
*
|
*
|
||||||
* Fetches all initiatedName values and aggregates first letters in JavaScript
|
* Uses the BFF /disciples/letters endpoint which returns pre-aggregated data.
|
||||||
* (Prisma/MongoDB doesn't support substring aggregations easily).
|
|
||||||
* Returns results sorted alphabetically by letter.
|
* Returns results sorted alphabetically by letter.
|
||||||
*/
|
*/
|
||||||
export async function getDiscipleLetterCounts(): Promise<LetterCount[]> {
|
export async function getDiscipleLetterCounts(): Promise<LetterCount[]> {
|
||||||
const disciples = await prisma.disciple.findMany({
|
const letters = await bffList<BffLetterCount>("/disciples/letters");
|
||||||
select: { initiatedName: true },
|
return letters
|
||||||
});
|
.map((l) => ({ letter: l._id, count: l.count }))
|
||||||
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const d of disciples) {
|
|
||||||
if (d.initiatedName && d.initiatedName.length > 0) {
|
|
||||||
const letter = d.initiatedName[0].toUpperCase();
|
|
||||||
counts[letter] = (counts[letter] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(counts)
|
|
||||||
.map(([letter, count]) => ({ letter, count }))
|
|
||||||
.sort((a, b) => a.letter.localeCompare(b.letter));
|
.sort((a, b) => a.letter.localeCompare(b.letter));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,19 +191,18 @@ export async function getDiscipleLetterCounts(): Promise<LetterCount[]> {
|
|||||||
/**
|
/**
|
||||||
* Get unique initiation places with disciple counts.
|
* Get unique initiation places with disciple counts.
|
||||||
*
|
*
|
||||||
* Fetches all non-null initiationPlace values and aggregates in JavaScript.
|
* No dedicated BFF endpoint — fetches all disciples and aggregates in JS.
|
||||||
* Returns results sorted by count descending.
|
* Returns results sorted by count descending.
|
||||||
|
*
|
||||||
|
* TODO: Add a BFF endpoint (GET /api/v1/disciples/places) for better performance.
|
||||||
*/
|
*/
|
||||||
export async function getDisciplePlaces(): Promise<PlaceGroup[]> {
|
export async function getDisciplePlaces(): Promise<PlaceGroup[]> {
|
||||||
const disciples = await prisma.disciple.findMany({
|
const disciples = await bffList<BffDisciple>("/disciples");
|
||||||
where: { initiationPlace: { not: null } },
|
|
||||||
select: { initiationPlace: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const d of disciples) {
|
for (const d of disciples) {
|
||||||
if (d.initiationPlace) {
|
if (d.initiation_place) {
|
||||||
counts[d.initiationPlace] = (counts[d.initiationPlace] || 0) + 1;
|
counts[d.initiation_place] = (counts[d.initiation_place] || 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,17 +214,20 @@ export async function getDisciplePlaces(): Promise<PlaceGroup[]> {
|
|||||||
/**
|
/**
|
||||||
* Get all disciples initiated at a given place.
|
* Get all disciples initiated at a given place.
|
||||||
*
|
*
|
||||||
|
* No dedicated BFF endpoint — fetches all disciples and filters in JS.
|
||||||
* Results are ordered by initiatedName ascending.
|
* Results are ordered by initiatedName ascending.
|
||||||
|
*
|
||||||
|
* TODO: Add BFF query param support (GET /api/v1/disciples?place=...) for better performance.
|
||||||
*/
|
*/
|
||||||
export async function getDisciplesByPlace(
|
export async function getDisciplesByPlace(
|
||||||
place: string,
|
place: string,
|
||||||
): Promise<DiscipleSummary[]> {
|
): Promise<DiscipleSummary[]> {
|
||||||
const disciples = await prisma.disciple.findMany({
|
const disciples = await bffList<BffDisciple>("/disciples");
|
||||||
where: { initiationPlace: place },
|
|
||||||
orderBy: { initiatedName: "asc" },
|
return disciples
|
||||||
select: discipleSummarySelect,
|
.filter((d) => d.initiation_place === place)
|
||||||
});
|
.map(mapDisciple)
|
||||||
return disciples;
|
.sort((a, b) => a.initiatedName.localeCompare(b.initiatedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Year-based queries ---
|
// --- Year-based queries ---
|
||||||
@@ -242,23 +235,23 @@ export async function getDisciplesByPlace(
|
|||||||
/**
|
/**
|
||||||
* Get unique initiation years with disciple counts.
|
* Get unique initiation years with disciple counts.
|
||||||
*
|
*
|
||||||
* Fetches all non-null initiationDate values and aggregates years in JavaScript
|
* No dedicated BFF endpoint — fetches all disciples and aggregates years in JS.
|
||||||
* (Prisma/MongoDB doesn't support date-part aggregations easily).
|
|
||||||
* Returns results sorted by year ascending.
|
* Returns results sorted by year ascending.
|
||||||
|
*
|
||||||
|
* TODO: Add a BFF endpoint (GET /api/v1/disciples/years) for better performance.
|
||||||
*/
|
*/
|
||||||
export async function getDiscipleYears(): Promise<YearGroup[]> {
|
export async function getDiscipleYears(): Promise<YearGroup[]> {
|
||||||
const disciples = await prisma.disciple.findMany({
|
const disciples = await bffList<BffDisciple>("/disciples");
|
||||||
where: { initiationDate: { not: null } },
|
|
||||||
select: { initiationDate: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const counts: Record<number, number> = {};
|
const counts: Record<number, number> = {};
|
||||||
for (const d of disciples) {
|
for (const d of disciples) {
|
||||||
if (d.initiationDate) {
|
if (d.initiation_date) {
|
||||||
const year = d.initiationDate.getFullYear();
|
const year = new Date(d.initiation_date).getFullYear();
|
||||||
|
if (!isNaN(year)) {
|
||||||
counts[year] = (counts[year] || 0) + 1;
|
counts[year] = (counts[year] || 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Object.entries(counts)
|
return Object.entries(counts)
|
||||||
.map(([year, count]) => ({ year: Number(year), count }))
|
.map(([year, count]) => ({ year: Number(year), count }))
|
||||||
@@ -268,27 +261,24 @@ export async function getDiscipleYears(): Promise<YearGroup[]> {
|
|||||||
/**
|
/**
|
||||||
* Get all disciples initiated in a given year.
|
* Get all disciples initiated in a given year.
|
||||||
*
|
*
|
||||||
* Uses a date range query (gte: Jan 1 of year, lt: Jan 1 of year+1)
|
* No dedicated BFF endpoint — fetches all disciples and filters in JS.
|
||||||
* to match all initiationDate values within the calendar year.
|
|
||||||
* Results are ordered by initiatedName ascending.
|
* Results are ordered by initiatedName ascending.
|
||||||
|
*
|
||||||
|
* TODO: Add BFF query param support (GET /api/v1/disciples?year=...) for better performance.
|
||||||
*/
|
*/
|
||||||
export async function getDisciplesByYear(
|
export async function getDisciplesByYear(
|
||||||
year: number,
|
year: number,
|
||||||
): Promise<DiscipleSummary[]> {
|
): Promise<DiscipleSummary[]> {
|
||||||
const startOfYear = new Date(year, 0, 1);
|
const disciples = await bffList<BffDisciple>("/disciples");
|
||||||
const startOfNextYear = new Date(year + 1, 0, 1);
|
|
||||||
|
|
||||||
const disciples = await prisma.disciple.findMany({
|
return disciples
|
||||||
where: {
|
.filter((d) => {
|
||||||
initiationDate: {
|
if (!d.initiation_date) return false;
|
||||||
gte: startOfYear,
|
const dYear = new Date(d.initiation_date).getFullYear();
|
||||||
lt: startOfNextYear,
|
return dYear === year;
|
||||||
},
|
})
|
||||||
},
|
.map(mapDisciple)
|
||||||
orderBy: { initiatedName: "asc" },
|
.sort((a, b) => a.initiatedName.localeCompare(b.initiatedName));
|
||||||
select: discipleSummarySelect,
|
|
||||||
});
|
|
||||||
return disciples;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Search ---
|
// --- Search ---
|
||||||
@@ -296,22 +286,14 @@ export async function getDisciplesByYear(
|
|||||||
/**
|
/**
|
||||||
* Search disciples by initiatedName using case-insensitive matching.
|
* Search disciples by initiatedName using case-insensitive matching.
|
||||||
*
|
*
|
||||||
* Uses Prisma's `contains` filter with case-insensitive mode.
|
* Uses BFF search endpoint.
|
||||||
* Results are limited to 100 and ordered by initiatedName ascending.
|
* Results are limited to 100 and ordered by initiatedName ascending.
|
||||||
*/
|
*/
|
||||||
export async function searchDisciples(
|
export async function searchDisciples(
|
||||||
query: string,
|
query: string,
|
||||||
): Promise<DiscipleSummary[]> {
|
): Promise<DiscipleSummary[]> {
|
||||||
const disciples = await prisma.disciple.findMany({
|
const disciples = await bffList<BffDisciple>("/disciples/search", {
|
||||||
where: {
|
params: { q: query },
|
||||||
initiatedName: {
|
|
||||||
contains: query,
|
|
||||||
mode: "insensitive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { initiatedName: "asc" },
|
|
||||||
take: 100,
|
|
||||||
select: discipleSummarySelect,
|
|
||||||
});
|
});
|
||||||
return disciples;
|
return disciples.map(mapDisciple);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,325 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Dialectic Spiritualism (DS).
|
* Data access layer for Dialectic Spiritualism (DS).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* DS is a large prose/dialogue book with 3 intro segments (front-pages,
|
|
||||||
* foreword, preface) and 13 parent chapters, each containing philosopher
|
|
||||||
* sub-chapters (e.g., "1-1" Socrates, "1-2" Plato, "1-3" Aristotle).
|
|
||||||
* 38 philosopher sub-chapters total.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
getNavigableItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "ds";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "ds";
|
export type DsMeta = BookMeta;
|
||||||
|
export type DsSubChapterEntry = { slug: string; title: string };
|
||||||
|
export type DsIntroSegment = IntroSegment;
|
||||||
|
export type DsChapter = Chapter;
|
||||||
|
export type DsSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getDsMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type DsMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: DsParentChapter[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DsParentChapter = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
subChapters: DsSubChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DsSubChapterEntry = {
|
|
||||||
slug: string; // "1-1", "1-2", etc.
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DsIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DsChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: string; // "1-1", "2-1", etc.
|
|
||||||
parentChapter: number;
|
|
||||||
subChapter: number;
|
|
||||||
title: string;
|
|
||||||
parentTitle: string;
|
|
||||||
type: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type DsSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1-1", "parent-1", etc. */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "parent" | "sub-chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Parent chapter number, or null for intro segments */
|
|
||||||
parentChapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getDsIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getDsIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getDsChapter(slug: string) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, slug);
|
||||||
}
|
}
|
||||||
|
export async function getDsContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
|
|
||||||
*/
|
|
||||||
export async function getDsMeta(): Promise<DsMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as DsMeta;
|
|
||||||
}
|
}
|
||||||
|
export async function getDsSidebarItems() {
|
||||||
// ---------------------------------------------------------------------------
|
return getSidebarItems(CODE);
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getDsIntroSegments(): Promise<DsIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getDsIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<DsIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters (sub-chapters like "1-1", "2-3", etc.)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sub-chapter by its slug (e.g. "1-1", "2-3").
|
|
||||||
*/
|
|
||||||
export async function getDsChapter(slug: string): Promise<DsChapter | null> {
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
// Parse parentChapter and subChapter from slug "X-Y"
|
|
||||||
const parts = slug.split("-");
|
|
||||||
const parentChapter = parseInt(parts[0], 10);
|
|
||||||
const subChapter = parseInt(parts[1], 10);
|
|
||||||
|
|
||||||
// Derive parentTitle from meta if available
|
|
||||||
const meta = await getDsMeta();
|
|
||||||
const parentEntry = meta?.chapters.find((ch) => ch.number === parentChapter);
|
|
||||||
const parentTitle = parentEntry?.title ?? "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: slug,
|
|
||||||
parentChapter,
|
|
||||||
subChapter,
|
|
||||||
title: doc.title,
|
|
||||||
parentTitle,
|
|
||||||
type: doc.type,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "intro-foreword" → intro segment
|
|
||||||
* "intro-preface" → intro segment
|
|
||||||
* "1-1", "2-3", etc. → sub-chapter
|
|
||||||
*/
|
|
||||||
export async function getDsContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<DsChapter | DsIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getDsIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-chapter slug like "1-1", "2-3"
|
|
||||||
return getDsChapter(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list with hierarchical structure.
|
|
||||||
*
|
|
||||||
* Order: front-pages → foreword → preface
|
|
||||||
* → parent-1 (label) → 1-1, 1-2, 1-3 → parent-2 (label) → 2-1, 2-2, ...
|
|
||||||
*
|
|
||||||
* "parent" type items are non-clickable headings. Only "intro-segment" and
|
|
||||||
* "sub-chapter" items are navigable pages.
|
|
||||||
*/
|
|
||||||
export async function getDsSidebarItems(): Promise<DsSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getDsMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
title: true,
|
|
||||||
type: true,
|
|
||||||
chapter: true,
|
|
||||||
order: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: DsSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (all come before chapters — no back matter for DS)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
parentChapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters — grouped by parent chapter with sub-chapters
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
// Parent heading (non-clickable label)
|
|
||||||
items.push({
|
|
||||||
slug: `parent-${ch.number}`,
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "parent",
|
|
||||||
parentChapter: ch.number,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sub-chapters under this parent
|
|
||||||
for (const sub of ch.subChapters) {
|
|
||||||
items.push({
|
|
||||||
slug: sub.slug,
|
|
||||||
label: sub.title,
|
|
||||||
type: "sub-chapter",
|
|
||||||
parentChapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the flat list of navigable sidebar items (excludes "parent" headings).
|
|
||||||
* Used for prev/next navigation.
|
|
||||||
*/
|
|
||||||
export function getNavigableItems(items: DsSidebarItem[]): DsSidebarItem[] {
|
|
||||||
return items.filter((item) => item.type !== "parent");
|
|
||||||
}
|
}
|
||||||
|
export { getNavigableItems };
|
||||||
|
|||||||
@@ -1,282 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Easy Journey to Other Planets (EJ).
|
* Data access layer for Easy Journey to Other Planets (EJ).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* EJ is a simple prose book with 2 chapters and 2 intro segments.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "ej";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "ej";
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
|
export type EjMeta = BookMeta;
|
||||||
|
export type EjChapterEntry = { number: number; title: string };
|
||||||
|
export type EjIntroSegment = IntroSegment;
|
||||||
|
export type EjChapter = Chapter;
|
||||||
|
export type EjSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type EjMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: EjChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EjChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EjIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EjChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type EjSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", "2" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/nod.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getEjMeta(): Promise<EjMeta | null> {
|
export async function getEjMeta(): Promise<EjMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
return getBookMeta(CODE);
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as EjMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getEjIntroSegments(): Promise<EjIntroSegment[]> {
|
export async function getEjIntroSegments(): Promise<EjIntroSegment[]> {
|
||||||
const results = await prisma.proseChapter.findMany({
|
return getIntroSegments(CODE);
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getEjIntroSegment(
|
export async function getEjIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<EjIntroSegment | null> {
|
): Promise<EjIntroSegment | null> {
|
||||||
// DB stores with "intro-" prefix; accept either form
|
return getIntroSegment(CODE, slug);
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–2).
|
|
||||||
*/
|
|
||||||
export async function getEjChapter(
|
export async function getEjChapter(
|
||||||
chapterNum: number,
|
chapterNum: number,
|
||||||
): Promise<EjChapter | null> {
|
): Promise<EjChapter | null> {
|
||||||
if (chapterNum < 1 || chapterNum > 2 || !Number.isInteger(chapterNum)) {
|
return getChapter(CODE, String(chapterNum));
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "intro-preface" → intro segment
|
|
||||||
* "1", "2" → chapter
|
|
||||||
*/
|
|
||||||
export async function getEjContent(
|
export async function getEjContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<EjChapter | EjIntroSegment | null> {
|
): Promise<EjChapter | EjIntroSegment | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
return getContent(CODE, slug);
|
||||||
return getEjIntroSegment(slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 2) {
|
|
||||||
return getEjChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → preface → chapters 1–2
|
|
||||||
*/
|
|
||||||
export async function getEjSidebarItems(): Promise<EjSidebarItem[]> {
|
export async function getEjSidebarItems(): Promise<EjSidebarItem[]> {
|
||||||
const [meta, allDocs] = await Promise.all([
|
return getSidebarItems(CODE);
|
||||||
getEjMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: EjSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–2 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,280 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Elevation to Kṛṣṇa Consciousness (EK).
|
* Data access layer for Elevation to Kṛṣṇa Consciousness (EK).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* EK is a simple prose book with 1 intro segment (front-pages) and 6 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "ek";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "ek";
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
|
export type EkMeta = BookMeta;
|
||||||
|
export type EkChapterEntry = { number: number; title: string };
|
||||||
|
export type EkIntroSegment = IntroSegment;
|
||||||
|
export type EkChapter = Chapter;
|
||||||
|
export type EkSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type EkMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: EkChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EkChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EkIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EkChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type EkSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "6" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getEkMeta(): Promise<EkMeta | null> {
|
export async function getEkMeta(): Promise<EkMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
return getBookMeta(CODE);
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as EkMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getEkIntroSegments(): Promise<EkIntroSegment[]> {
|
export async function getEkIntroSegments(): Promise<EkIntroSegment[]> {
|
||||||
const results = await prisma.proseChapter.findMany({
|
return getIntroSegments(CODE);
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getEkIntroSegment(
|
export async function getEkIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<EkIntroSegment | null> {
|
): Promise<EkIntroSegment | null> {
|
||||||
// DB stores with "intro-" prefix; accept either form
|
return getIntroSegment(CODE, slug);
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–6).
|
|
||||||
*/
|
|
||||||
export async function getEkChapter(
|
export async function getEkChapter(
|
||||||
chapterNum: number,
|
chapterNum: number,
|
||||||
): Promise<EkChapter | null> {
|
): Promise<EkChapter | null> {
|
||||||
if (chapterNum < 1 || chapterNum > 6 || !Number.isInteger(chapterNum)) {
|
return getChapter(CODE, String(chapterNum));
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "6" → chapter
|
|
||||||
*/
|
|
||||||
export async function getEkContent(
|
export async function getEkContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<EkChapter | EkIntroSegment | null> {
|
): Promise<EkChapter | EkIntroSegment | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
return getContent(CODE, slug);
|
||||||
return getEkIntroSegment(slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
|
|
||||||
return getEkChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–6
|
|
||||||
*/
|
|
||||||
export async function getEkSidebarItems(): Promise<EkSidebarItem[]> {
|
export async function getEkSidebarItems(): Promise<EkSidebarItem[]> {
|
||||||
const [meta, allDocs] = await Promise.all([
|
return getSidebarItems(CODE);
|
||||||
getEkMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: EkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–6 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,443 +1,39 @@
|
|||||||
/**
|
import {
|
||||||
* Data access layer for Gītār Gān (GG).
|
getBookMeta,
|
||||||
*
|
getIntroSegments,
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
getIntroSegment,
|
||||||
* GG is a prose/poetry book with no volume structure —
|
getChapter,
|
||||||
* we use volume=0 as the sentinel for "no volumes".
|
getContent,
|
||||||
*
|
getSidebarItems,
|
||||||
* When the DB isn't seeded yet, falls back to reading local JSON files
|
type BookMeta,
|
||||||
* from lib/jsons/books/gg/.
|
type IntroSegment,
|
||||||
*
|
type Chapter,
|
||||||
* Collections used:
|
type SidebarItem,
|
||||||
* - book_meta (via Prisma BookMeta model)
|
} from "@/lib/books";
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
const CODE = "gg";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export type GgMeta = BookMeta;
|
||||||
// Constants
|
export type GgChapterEntry = { number: number; title: string };
|
||||||
// ---------------------------------------------------------------------------
|
export type GgIntroSegment = IntroSegment;
|
||||||
|
export type GgChapter = Chapter;
|
||||||
|
export type GgSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getGgMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
const BOOK = "gg";
|
|
||||||
|
|
||||||
const GG_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "gg");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type GgMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: GgChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GgChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GgIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GgChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type GgSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-introduction", "1", "18" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/api.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getGgIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getGgIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getGgChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getGgContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
export async function getGgSidebarItems() {
|
||||||
|
return getSidebarItems(CODE);
|
||||||
function getLocalIntroSegments(): GgIntroSegment[] {
|
|
||||||
const introDir = path.join(GG_JSON_DIR, "intro");
|
|
||||||
if (!fs.existsSync(introDir)) return [];
|
|
||||||
|
|
||||||
const results: GgIntroSegment[] = [];
|
|
||||||
const files = ["introduction.json"];
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(introDir, file);
|
|
||||||
const seg = readLocalJson<GgIntroSegment>(filePath);
|
|
||||||
if (seg) results.push(seg);
|
|
||||||
}
|
|
||||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalIntroSegment(rawSlug: string): GgIntroSegment | null {
|
|
||||||
const filePath = path.join(GG_JSON_DIR, "intro", `${rawSlug}.json`);
|
|
||||||
return readLocalJson<GgIntroSegment>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): GgChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
GG_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<GgChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getGgMeta(): Promise<GgMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as GgMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(GG_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<GgMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getGgIntroSegments(): Promise<GgIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
return getLocalIntroSegments();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getGgIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<GgIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalIntroSegment(rawSlug);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
slug: dbSlug,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–18).
|
|
||||||
*/
|
|
||||||
export async function getGgChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<GgChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 18 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalChapter(chapterNum);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "1" … "18" → chapter
|
|
||||||
*/
|
|
||||||
export async function getGgContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<GgChapter | GgIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getGgIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
|
|
||||||
return getGgChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: introduction → chapters 1–18
|
|
||||||
*/
|
|
||||||
export async function getGgSidebarItems(): Promise<GgSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getGgMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If we got DB results, build from those
|
|
||||||
if (meta && allDocs.length > 0) {
|
|
||||||
return buildSidebarFromDocs(meta, allDocs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: build from local JSON
|
|
||||||
return buildSidebarFromLocal(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromDocs(
|
|
||||||
meta: GgMeta,
|
|
||||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
|
||||||
): GgSidebarItem[] {
|
|
||||||
const items: GgSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments that come BEFORE chapters (order < 99)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–18 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intro segments that come AFTER chapters (order >= 99)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: GgMeta | null): GgSidebarItem[] {
|
|
||||||
const items: GgSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Local intro segments (front matter)
|
|
||||||
const introSegs = getLocalIntroSegments();
|
|
||||||
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
|
|
||||||
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
|
|
||||||
|
|
||||||
for (const seg of frontMatter) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters from meta or generate default 1-18
|
|
||||||
if (meta && meta.chapters) {
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: scan local chapter JSONs
|
|
||||||
const chaptersDir = path.join(GG_JSON_DIR, "chapters");
|
|
||||||
if (fs.existsSync(chaptersDir)) {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(chaptersDir)
|
|
||||||
.filter((f) => f.endsWith(".json"))
|
|
||||||
.sort();
|
|
||||||
for (const file of files) {
|
|
||||||
const ch = readLocalJson<GgChapter>(path.join(chaptersDir, file));
|
|
||||||
if (ch) {
|
|
||||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
|
||||||
items.push({
|
|
||||||
slug: String(num),
|
|
||||||
label: `Chapter ${num}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: num,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back matter
|
|
||||||
for (const seg of backMatter) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-269
@@ -1,282 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for The Kṛṣṇa Consciousness Handbook (HKH).
|
* Data access layer for The Hare Kṛṣṇa Handbook (HKH).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* HKH is a prose book with 2 intro segments (front-pages, introduction)
|
|
||||||
* and 19 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "hkh";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "hkh";
|
export type HkhMeta = BookMeta;
|
||||||
|
export type HkhChapterEntry = { number: number; title: string };
|
||||||
|
export type HkhIntroSegment = IntroSegment;
|
||||||
|
export type HkhChapter = Chapter;
|
||||||
|
export type HkhSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getHkhMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type HkhMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: HkhChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HkhChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HkhIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HkhChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type HkhSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "intro-introduction", "1", "2", … "7" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getHkhIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getHkhIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getHkhChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getHkhContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getHkhMeta(): Promise<HkhMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as HkhMeta;
|
|
||||||
}
|
}
|
||||||
|
export async function getHkhSidebarItems() {
|
||||||
// ---------------------------------------------------------------------------
|
return getSidebarItems(CODE);
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getHkhIntroSegments(): Promise<HkhIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getHkhIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<HkhIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–19).
|
|
||||||
*/
|
|
||||||
export async function getHkhChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<HkhChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 19 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "1" … "7" → chapter
|
|
||||||
*/
|
|
||||||
export async function getHkhContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<HkhChapter | HkhIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getHkhIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 19) {
|
|
||||||
return getHkhChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → introduction → chapters 1–19
|
|
||||||
*/
|
|
||||||
export async function getHkhSidebarItems(): Promise<HkhSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getHkhMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: HkhSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–19 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-313
@@ -1,225 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Śrī Īśopaniṣad (ISO).
|
* Data access layer for Śrī Īśopaniṣad (ISO).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer (BFF API) for prose intro segments,
|
||||||
* ISO is unique — it has BOTH verses (in the `verses` collection)
|
* and to lib/api.ts for verse (mantra) lookups.
|
||||||
* AND prose intro segments (in the `prose_chapters` collection).
|
|
||||||
*
|
|
||||||
* Verses were seeded with:
|
|
||||||
* - book: "iso", division: null
|
|
||||||
* - chapter = mantra number (0 for Invocation, 1-18 for mantras)
|
|
||||||
* - verse = always 1
|
|
||||||
*
|
|
||||||
* Intro segments were seeded with:
|
|
||||||
* - book: "iso", volume: 0, type: "intro-segment"
|
|
||||||
* - slugs like "intro-front-pages", "intro-introduction", "intro-about-the-author"
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
import { getSingleVerse, type VerseRecord } from "@/lib/api";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "iso";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "iso";
|
export type IsoMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type IsoIntroSegment = IntroSegment;
|
||||||
|
export type IsoChapter = Chapter;
|
||||||
|
export type IsoSidebarItem = SidebarItem;
|
||||||
|
export type IsoVerse = VerseRecord;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export async function getIsoMeta() {
|
||||||
// Mantra title lookup
|
return getBookMeta(CODE);
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const MANTRA_TITLES: Record<number, string> = {
|
|
||||||
0: "Invocation",
|
|
||||||
1: "Mantra One",
|
|
||||||
2: "Mantra Two",
|
|
||||||
3: "Mantra Three",
|
|
||||||
4: "Mantra Four",
|
|
||||||
5: "Mantra Five",
|
|
||||||
6: "Mantra Six",
|
|
||||||
7: "Mantra Seven",
|
|
||||||
8: "Mantra Eight",
|
|
||||||
9: "Mantra Nine",
|
|
||||||
10: "Mantra Ten",
|
|
||||||
11: "Mantra Eleven",
|
|
||||||
12: "Mantra Twelve",
|
|
||||||
13: "Mantra Thirteen",
|
|
||||||
14: "Mantra Fourteen",
|
|
||||||
15: "Mantra Fifteen",
|
|
||||||
16: "Mantra Sixteen",
|
|
||||||
17: "Mantra Seventeen",
|
|
||||||
18: "Mantra Eighteen",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type IsoMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalMantras: number;
|
|
||||||
mantras: IsoMantraEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IsoMantraEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IsoIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IsoVerse = {
|
|
||||||
book: string;
|
|
||||||
book_title: string;
|
|
||||||
mantra: number;
|
|
||||||
title: string;
|
|
||||||
reference: string;
|
|
||||||
devanagari: string;
|
|
||||||
transliteration: string;
|
|
||||||
synonyms: { word: string; meanings: string[] }[];
|
|
||||||
translation: string;
|
|
||||||
purport: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type IsoSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "invocation", "1", "intro-about-the-author" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "verse" */
|
|
||||||
type: string;
|
|
||||||
/** Mantra number (0 for invocation), or null for non-verse items */
|
|
||||||
mantra: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/nod.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getIsoIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getIsoIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize purport (string[] from Prisma) via normalizeParagraphs. */
|
|
||||||
function normalizePurport(purport: string[] | JsonValue): string[] {
|
|
||||||
return normalizeParagraphs(purport);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, mantras list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getIsoMeta(): Promise<IsoMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as IsoMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments (from prose_chapters collection)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getIsoIntroSegments(): Promise<IsoIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getIsoIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<IsoIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Verses (from verses collection)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single verse/mantra by slug.
|
* A single verse/mantra by slug.
|
||||||
*
|
*
|
||||||
* - "invocation" → chapter=0, verse=1
|
* ISO verses are stored with: book "iso", no division,
|
||||||
* - "1"-"18" → chapter=1–18, verse=1
|
* chapter = mantra number (0 for invocation), verse = 1.
|
||||||
*/
|
*/
|
||||||
export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
|
export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
|
||||||
let mantraNum: number;
|
let mantraNum: number;
|
||||||
@@ -227,119 +49,25 @@ export async function getIsoVerse(slug: string): Promise<IsoVerse | null> {
|
|||||||
if (slug === "invocation") {
|
if (slug === "invocation") {
|
||||||
mantraNum = 0;
|
mantraNum = 0;
|
||||||
} else {
|
} else {
|
||||||
const num = Number(slug);
|
const num = parseInt(slug, 10);
|
||||||
if (!Number.isInteger(num) || num < 1 || num > 18) return null;
|
if (isNaN(num) || num < 1 || num > 18) return null;
|
||||||
mantraNum = num;
|
mantraNum = num;
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.verse.findFirst({
|
return getSingleVerse(CODE, null, mantraNum, 1);
|
||||||
where: { book: BOOK, division: null, chapter: mantraNum, verse: 1 },
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
book_title: doc.bookTitle,
|
|
||||||
mantra: doc.chapter, // chapter field stores the mantra number
|
|
||||||
title: MANTRA_TITLES[doc.chapter] ?? `Mantra ${doc.chapter}`,
|
|
||||||
reference: doc.reference,
|
|
||||||
devanagari: doc.devanagari,
|
|
||||||
transliteration: doc.transliteration,
|
|
||||||
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
|
|
||||||
translation: sanitizeHtml(doc.translation),
|
|
||||||
purport: normalizePurport(doc.purport),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "intro-about-the-author" → intro segment
|
|
||||||
* "invocation" → invocation verse
|
|
||||||
* "1" … "18" → mantra verse
|
|
||||||
*/
|
|
||||||
export async function getIsoContent(
|
export async function getIsoContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<IsoVerse | IsoIntroSegment | null> {
|
): Promise<IsoVerse | IsoIntroSegment | IsoChapter | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
// Try as verse/mantra first (numeric slug or "invocation")
|
||||||
return getIsoIntroSegment(slug);
|
if (slug === "invocation" || /^\d+$/.test(slug)) {
|
||||||
|
const verse = await getIsoVerse(slug);
|
||||||
|
if (verse) return verse;
|
||||||
}
|
}
|
||||||
|
// Fall back to prose content (intro segments, etc.)
|
||||||
if (slug === "invocation") {
|
return getContent(CODE, slug);
|
||||||
return getIsoVerse("invocation");
|
|
||||||
}
|
}
|
||||||
|
export async function getIsoSidebarItems() {
|
||||||
const num = Number(slug);
|
return getSidebarItems(CODE);
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
|
|
||||||
return getIsoVerse(String(num));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → introduction → invocation → mantras 1-18 → about-the-author
|
|
||||||
*/
|
|
||||||
export async function getIsoSidebarItems(): Promise<IsoSidebarItem[]> {
|
|
||||||
const meta = await getIsoMeta();
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: IsoSidebarItem[] = [];
|
|
||||||
|
|
||||||
// 1. Front pages intro segment
|
|
||||||
items.push({
|
|
||||||
slug: "intro-front-pages",
|
|
||||||
label: "Back Cover & Front Pages",
|
|
||||||
type: "intro-segment",
|
|
||||||
mantra: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Introduction (Teachings of the Vedas)
|
|
||||||
items.push({
|
|
||||||
slug: "intro-introduction",
|
|
||||||
label: "Introduction (Teachings of the Vedas)",
|
|
||||||
type: "intro-segment",
|
|
||||||
mantra: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Invocation + Mantras 1-18 (from meta.mantras)
|
|
||||||
for (const m of meta.mantras) {
|
|
||||||
if (m.number === 0) {
|
|
||||||
items.push({
|
|
||||||
slug: "invocation",
|
|
||||||
label: "Invocation",
|
|
||||||
type: "verse",
|
|
||||||
mantra: 0,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
slug: String(m.number),
|
|
||||||
label: `${m.number}. ${m.title}`,
|
|
||||||
type: "verse",
|
|
||||||
mantra: m.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. About the Author intro segment (at the end)
|
|
||||||
items.push({
|
|
||||||
slug: "intro-about-the-author",
|
|
||||||
label: "About the Author",
|
|
||||||
type: "intro-segment",
|
|
||||||
mantra: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-35
@@ -1,13 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Journals.
|
* Data access layer for Journals.
|
||||||
*
|
*
|
||||||
* Uses dedicated Prisma models:
|
* Uses the BFF API instead of direct Prisma queries.
|
||||||
* - JournalMeta (collection: journal_meta)
|
* Falls back to local JSON files when BFF is unavailable.
|
||||||
* - JournalChapter (collection: journal_chapters)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { bff, bffList } from "@/lib/bff";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
@@ -65,6 +63,31 @@ export type JournalSidebarItem = {
|
|||||||
chapter: number;
|
chapter: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- BFF response types ---
|
||||||
|
|
||||||
|
type BffJournalMeta = {
|
||||||
|
id: string;
|
||||||
|
book: string;
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
year: number | null;
|
||||||
|
description: string | null;
|
||||||
|
totalChapters: number;
|
||||||
|
chapters: JournalChapterEntry[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type BffJournalChapter = {
|
||||||
|
book: string;
|
||||||
|
chapter: number;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
slug: string;
|
||||||
|
paragraphs: string[];
|
||||||
|
paragraphs_json?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// HTML sanitisation
|
// HTML sanitisation
|
||||||
function sanitizeHtml(html: string): string {
|
function sanitizeHtml(html: string): string {
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||||
@@ -80,7 +103,7 @@ function sanitizeHtml(html: string): string {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
function normalizeParagraphs(paragraphs: string[] | unknown): string[] {
|
||||||
let result: string[];
|
let result: string[];
|
||||||
if (typeof paragraphs === "string") {
|
if (typeof paragraphs === "string") {
|
||||||
try {
|
try {
|
||||||
@@ -97,6 +120,51 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|||||||
return result.map(sanitizeHtml);
|
return result.map(sanitizeHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- BFF → domain mappers ---
|
||||||
|
|
||||||
|
function mapBffMetaToJournalMetaData(raw: BffJournalMeta): JournalMetaData {
|
||||||
|
return {
|
||||||
|
book: raw.book,
|
||||||
|
title: raw.data.title,
|
||||||
|
author: raw.data.author,
|
||||||
|
year: raw.data.year ?? null,
|
||||||
|
description: raw.data.description ?? null,
|
||||||
|
totalChapters: raw.data.totalChapters,
|
||||||
|
chapters: raw.data.chapters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBffMetaToSummary(raw: BffJournalMeta): JournalSummary {
|
||||||
|
return {
|
||||||
|
book: raw.book,
|
||||||
|
title: raw.data.title,
|
||||||
|
author: raw.data.author,
|
||||||
|
year: raw.data.year ?? null,
|
||||||
|
description: raw.data.description ?? null,
|
||||||
|
totalChapters: raw.data.totalChapters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBffChapter(raw: BffJournalChapter): JournalChapterData {
|
||||||
|
// Handle paragraphs_json fallback
|
||||||
|
let paragraphs = raw.paragraphs;
|
||||||
|
if ((!paragraphs || !paragraphs.length) && raw.paragraphs_json) {
|
||||||
|
try {
|
||||||
|
paragraphs = JSON.parse(raw.paragraphs_json);
|
||||||
|
} catch {
|
||||||
|
paragraphs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
book: raw.book,
|
||||||
|
chapter: raw.chapter,
|
||||||
|
title: raw.title,
|
||||||
|
type: raw.type,
|
||||||
|
paragraphs: normalizeParagraphs(paragraphs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Local JSON fallback helpers
|
// Local JSON fallback helpers
|
||||||
function findLocalDirForBook(bookCode: string): string | null {
|
function findLocalDirForBook(bookCode: string): string | null {
|
||||||
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
|
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
|
||||||
@@ -160,21 +228,9 @@ function getLocalChapterBySlug(
|
|||||||
// Public API
|
// Public API
|
||||||
export async function getAllJournals(): Promise<JournalSummary[]> {
|
export async function getAllJournals(): Promise<JournalSummary[]> {
|
||||||
try {
|
try {
|
||||||
const results = await prisma.journalMeta.findMany({
|
const results = await bffList<BffJournalMeta>("/journals");
|
||||||
orderBy: { book: "asc" },
|
|
||||||
});
|
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
return results.map((row) => {
|
return results.map(mapBffMetaToSummary);
|
||||||
const data = row.data as unknown as JournalMetaData;
|
|
||||||
return {
|
|
||||||
book: row.book,
|
|
||||||
title: data.title,
|
|
||||||
author: data.author,
|
|
||||||
year: data.year ?? null,
|
|
||||||
description: data.description ?? null,
|
|
||||||
totalChapters: data.totalChapters,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
@@ -194,10 +250,8 @@ export async function getJournalMeta(
|
|||||||
bookCode: string,
|
bookCode: string,
|
||||||
): Promise<JournalMetaData | null> {
|
): Promise<JournalMetaData | null> {
|
||||||
try {
|
try {
|
||||||
const row = await prisma.journalMeta.findUnique({
|
const raw = await bff<BffJournalMeta>(`/journals/${bookCode}`);
|
||||||
where: { book: bookCode },
|
if (raw) return mapBffMetaToJournalMetaData(raw);
|
||||||
});
|
|
||||||
if (row) return row.data as unknown as JournalMetaData;
|
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
}
|
}
|
||||||
@@ -210,17 +264,10 @@ export async function getJournalChapterBySlug(
|
|||||||
): Promise<JournalChapterData | null> {
|
): Promise<JournalChapterData | null> {
|
||||||
if (!slug) return null;
|
if (!slug) return null;
|
||||||
try {
|
try {
|
||||||
const doc = await prisma.journalChapter.findUnique({
|
const raw = await bff<BffJournalChapter>(
|
||||||
where: { book_slug: { book: bookCode, slug } },
|
`/journals/${bookCode}/chapters/${slug}`,
|
||||||
});
|
);
|
||||||
if (doc)
|
if (raw) return mapBffChapter(raw);
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-431
@@ -1,445 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Life Comes from Life (LCL).
|
* Data access layer for Life Comes from Life (LCL).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* LCL is a prose/dialogue book with no volume structure —
|
|
||||||
* we use volume=0 as the sentinel for "no volumes".
|
|
||||||
*
|
|
||||||
* When the DB isn't seeded yet, falls back to reading local JSON files
|
|
||||||
* from lib/jsons/books/lcl/.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
import fs from "fs";
|
getIntroSegments,
|
||||||
import path from "path";
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "lcl";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export type LclMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type LclChapterEntry = { number: number; title: string };
|
||||||
|
export type LclIntroSegment = IntroSegment;
|
||||||
|
export type LclChapter = Chapter;
|
||||||
|
export type LclSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "lcl";
|
export async function getLclMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
const LCL_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lcl");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type LclMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: LclChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LclChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LclIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LclChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type LclSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-foreword", "1", "16", "intro-about-the-author" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/api.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getLclIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getLclIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getLclChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getLclContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
export async function getLclSidebarItems() {
|
||||||
|
return getSidebarItems(CODE);
|
||||||
function getLocalIntroSegments(): LclIntroSegment[] {
|
|
||||||
const introDir = path.join(LCL_JSON_DIR, "intro");
|
|
||||||
if (!fs.existsSync(introDir)) return [];
|
|
||||||
|
|
||||||
const results: LclIntroSegment[] = [];
|
|
||||||
const files = ["foreword.json", "introduction.json", "about-the-author.json"];
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(introDir, file);
|
|
||||||
const seg = readLocalJson<LclIntroSegment>(filePath);
|
|
||||||
if (seg) results.push(seg);
|
|
||||||
}
|
|
||||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalIntroSegment(rawSlug: string): LclIntroSegment | null {
|
|
||||||
const filePath = path.join(LCL_JSON_DIR, "intro", `${rawSlug}.json`);
|
|
||||||
return readLocalJson<LclIntroSegment>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): LclChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
LCL_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<LclChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getLclMeta(): Promise<LclMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as LclMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(LCL_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<LclMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getLclIntroSegments(): Promise<LclIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
return getLocalIntroSegments();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-foreword" (URL slug) and "foreword" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getLclIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<LclIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalIntroSegment(rawSlug);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
slug: dbSlug,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–16).
|
|
||||||
*/
|
|
||||||
export async function getLclChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<LclChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 16 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalChapter(chapterNum);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-foreword" → intro segment
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "intro-about-the-author" → intro segment
|
|
||||||
* "1" … "16" → chapter
|
|
||||||
*/
|
|
||||||
export async function getLclContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<LclChapter | LclIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getLclIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 16) {
|
|
||||||
return getLclChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: foreword → introduction → chapters 1–16 → about-the-author
|
|
||||||
*/
|
|
||||||
export async function getLclSidebarItems(): Promise<LclSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getLclMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If we got DB results, build from those
|
|
||||||
if (meta && allDocs.length > 0) {
|
|
||||||
return buildSidebarFromDocs(meta, allDocs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: build from local JSON
|
|
||||||
return buildSidebarFromLocal(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromDocs(
|
|
||||||
meta: LclMeta,
|
|
||||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
|
||||||
): LclSidebarItem[] {
|
|
||||||
const items: LclSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments that come BEFORE chapters (order < 99)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 99) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–16 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intro segments that come AFTER chapters (order >= 99, e.g. about-the-author)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 99) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: LclMeta | null): LclSidebarItem[] {
|
|
||||||
const items: LclSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Local intro segments (front matter)
|
|
||||||
const introSegs = getLocalIntroSegments();
|
|
||||||
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 99);
|
|
||||||
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 99);
|
|
||||||
|
|
||||||
for (const seg of frontMatter) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters from meta or generate default 1-16
|
|
||||||
if (meta && meta.chapters) {
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: scan local chapter JSONs
|
|
||||||
const chaptersDir = path.join(LCL_JSON_DIR, "chapters");
|
|
||||||
if (fs.existsSync(chaptersDir)) {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(chaptersDir)
|
|
||||||
.filter((f) => f.endsWith(".json"))
|
|
||||||
.sort();
|
|
||||||
for (const file of files) {
|
|
||||||
const ch = readLocalJson<LclChapter>(path.join(chaptersDir, file));
|
|
||||||
if (ch) {
|
|
||||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
|
||||||
items.push({
|
|
||||||
slug: String(num),
|
|
||||||
label: `Chapter ${num}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: num,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back matter (about-the-author)
|
|
||||||
for (const seg of backMatter) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-121
@@ -2,7 +2,7 @@
|
|||||||
* Data access layer for Letters.
|
* Data access layer for Letters.
|
||||||
*
|
*
|
||||||
* Provides queries for browsing letters by year, recipient, and location.
|
* Provides queries for browsing letters by year, recipient, and location.
|
||||||
* Uses the Prisma Letter model (collection: letters).
|
* Calls the BFF API for all data access (no direct Prisma usage).
|
||||||
*
|
*
|
||||||
* Recipients are stored as an array (e.g., ["Mukunda", "Janaki"]).
|
* Recipients are stored as an array (e.g., ["Mukunda", "Janaki"]).
|
||||||
* The recipient list aggregates individual names across all letters,
|
* The recipient list aggregates individual names across all letters,
|
||||||
@@ -10,9 +10,12 @@
|
|||||||
* ANY recipient (individual or group).
|
* ANY recipient (individual or group).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { bff, bffList } from "@/lib/bff";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type LetterSummary = {
|
export type LetterSummary = {
|
||||||
slug: string;
|
slug: string;
|
||||||
recipients: string[];
|
recipients: string[];
|
||||||
@@ -52,7 +55,43 @@ export type LocationGroup = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sanitize
|
// ---------------------------------------------------------------------------
|
||||||
|
// BFF response types (snake_case from Go)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type BffLetterSummary = {
|
||||||
|
slug: string;
|
||||||
|
recipients: string[];
|
||||||
|
recipient_display: string;
|
||||||
|
date: string | null;
|
||||||
|
year: number | null;
|
||||||
|
month: number | null;
|
||||||
|
location: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BffLetterFull = {
|
||||||
|
slug: string;
|
||||||
|
recipients: string[];
|
||||||
|
recipient_display: string;
|
||||||
|
date: string | null;
|
||||||
|
date_ref: string | null;
|
||||||
|
year: number | null;
|
||||||
|
month: number | null;
|
||||||
|
day: number | null;
|
||||||
|
location: string | null;
|
||||||
|
salutation: string | null;
|
||||||
|
paragraphs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BffAggregation = {
|
||||||
|
_id: string | number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTML sanitisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
function sanitizeHtml(html: string): string {
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||||
@@ -65,23 +104,60 @@ function sanitizeHtml(html: string): string {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Index/overview queries ---
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mappers (BFF snake_case → frontend camelCase)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function mapLetterSummary(item: BffLetterSummary): LetterSummary {
|
||||||
|
return {
|
||||||
|
slug: item.slug,
|
||||||
|
recipients: item.recipients ?? [],
|
||||||
|
recipientDisplay: item.recipient_display ?? "",
|
||||||
|
date: item.date ?? null,
|
||||||
|
year: item.year ?? null,
|
||||||
|
month: item.month ?? null,
|
||||||
|
location: item.location ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLetterFull(item: BffLetterFull): LetterFull {
|
||||||
|
return {
|
||||||
|
slug: item.slug,
|
||||||
|
recipients: item.recipients ?? [],
|
||||||
|
recipientDisplay: item.recipient_display ?? "",
|
||||||
|
date: item.date ?? null,
|
||||||
|
dateRef: item.date_ref ?? null,
|
||||||
|
year: item.year ?? null,
|
||||||
|
month: item.month ?? null,
|
||||||
|
day: item.day ?? null,
|
||||||
|
location: item.location ?? null,
|
||||||
|
salutation: item.salutation ?? null,
|
||||||
|
paragraphs: (item.paragraphs ?? []).map(sanitizeHtml),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapYearGroup(item: BffAggregation): YearGroup {
|
||||||
|
return { year: Number(item._id), count: item.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRecipientGroup(item: BffAggregation): RecipientGroup {
|
||||||
|
return { name: String(item._id), count: item.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLocationGroup(item: BffAggregation): LocationGroup {
|
||||||
|
return { name: String(item._id), count: item.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Index / overview queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getLetterYears(): Promise<YearGroup[]> {
|
export async function getLetterYears(): Promise<YearGroup[]> {
|
||||||
const letters = await prisma.letter.findMany({
|
const raw = await bffList<BffAggregation>("/letters/years", {
|
||||||
where: { year: { not: null } },
|
tags: ["letters"],
|
||||||
select: { year: true },
|
|
||||||
});
|
});
|
||||||
|
return raw
|
||||||
const counts: Record<number, number> = {};
|
.map(mapYearGroup)
|
||||||
for (const l of letters) {
|
|
||||||
if (l.year) {
|
|
||||||
counts[l.year] = (counts[l.year] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(counts)
|
|
||||||
.map(([year, count]) => ({ year: Number(year), count }))
|
|
||||||
.filter((g) => g.year >= 1940 && g.year <= 1980)
|
.filter((g) => g.year >= 1940 && g.year <= 1980)
|
||||||
.sort((a, b) => a.year - b.year);
|
.sort((a, b) => a.year - b.year);
|
||||||
}
|
}
|
||||||
@@ -90,67 +166,39 @@ export async function getLetterYears(): Promise<YearGroup[]> {
|
|||||||
* Get all unique individual recipients with letter counts.
|
* Get all unique individual recipients with letter counts.
|
||||||
*
|
*
|
||||||
* Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]).
|
* Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]).
|
||||||
* This function aggregates across all letters so that "Mukunda" appears
|
* The BFF aggregation endpoint already aggregates across all letters so
|
||||||
* once with count = total letters where Mukunda is ANY recipient.
|
* that "Mukunda" appears once with count = total letters where Mukunda
|
||||||
|
* is ANY recipient.
|
||||||
*/
|
*/
|
||||||
export async function getLetterRecipients(): Promise<RecipientGroup[]> {
|
export async function getLetterRecipients(): Promise<RecipientGroup[]> {
|
||||||
const letters = await prisma.letter.findMany({
|
const raw = await bffList<BffAggregation>("/letters/recipients", {
|
||||||
select: { recipients: true },
|
tags: ["letters"],
|
||||||
});
|
});
|
||||||
|
return raw.map(mapRecipientGroup).sort((a, b) => b.count - a.count);
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const l of letters) {
|
|
||||||
for (const r of l.recipients) {
|
|
||||||
counts[r] = (counts[r] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(counts)
|
|
||||||
.map(([name, count]) => ({ name, count }))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLetterLocations(): Promise<LocationGroup[]> {
|
export async function getLetterLocations(): Promise<LocationGroup[]> {
|
||||||
const letters = await prisma.letter.findMany({
|
const raw = await bffList<BffAggregation>("/letters/locations", {
|
||||||
where: { location: { not: null } },
|
tags: ["letters"],
|
||||||
select: { location: true },
|
|
||||||
});
|
});
|
||||||
|
return raw.map(mapLocationGroup).sort((a, b) => b.count - a.count);
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const l of letters) {
|
|
||||||
if (l.location) {
|
|
||||||
counts[l.location] = (counts[l.location] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.entries(counts)
|
// ---------------------------------------------------------------------------
|
||||||
.map(([name, count]) => ({ name, count }))
|
// Letter list queries
|
||||||
.sort((a, b) => b.count - a.count);
|
// ---------------------------------------------------------------------------
|
||||||
}
|
|
||||||
|
|
||||||
// --- Letter list queries ---
|
|
||||||
|
|
||||||
export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
|
export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
|
||||||
const letters = await prisma.letter.findMany({
|
const raw = await bffList<BffLetterSummary>(`/letters/year/${year}`, {
|
||||||
where: { year },
|
tags: ["letters"],
|
||||||
orderBy: [{ month: "asc" }, { day: "asc" }],
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
recipients: true,
|
|
||||||
recipientDisplay: true,
|
|
||||||
date: true,
|
|
||||||
year: true,
|
|
||||||
month: true,
|
|
||||||
location: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return letters;
|
return raw.map(mapLetterSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all letters where the given name is ANY recipient.
|
* Get all letters where the given name is ANY recipient.
|
||||||
*
|
*
|
||||||
* Uses Prisma's `has` filter on the recipients array.
|
* The BFF endpoint handles the array-contains matching server-side.
|
||||||
* Searching for "Mukunda" returns letters addressed to:
|
* Searching for "Mukunda" returns letters addressed to:
|
||||||
* - "Mukunda" (individual)
|
* - "Mukunda" (individual)
|
||||||
* - "Mukunda, Janaki" (group containing Mukunda)
|
* - "Mukunda, Janaki" (group containing Mukunda)
|
||||||
@@ -159,63 +207,38 @@ export async function getLettersByYear(year: number): Promise<LetterSummary[]> {
|
|||||||
export async function getLettersByRecipient(
|
export async function getLettersByRecipient(
|
||||||
recipient: string,
|
recipient: string,
|
||||||
): Promise<LetterSummary[]> {
|
): Promise<LetterSummary[]> {
|
||||||
const letters = await prisma.letter.findMany({
|
const encoded = encodeURIComponent(recipient);
|
||||||
where: { recipients: { has: recipient } },
|
const raw = await bffList<BffLetterSummary>(`/letters/recipient/${encoded}`, {
|
||||||
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
tags: ["letters"],
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
recipients: true,
|
|
||||||
recipientDisplay: true,
|
|
||||||
date: true,
|
|
||||||
year: true,
|
|
||||||
month: true,
|
|
||||||
location: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return letters;
|
return raw.map(mapLetterSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLettersByLocation(
|
export async function getLettersByLocation(
|
||||||
location: string,
|
location: string,
|
||||||
): Promise<LetterSummary[]> {
|
): Promise<LetterSummary[]> {
|
||||||
const letters = await prisma.letter.findMany({
|
const encoded = encodeURIComponent(location);
|
||||||
where: { location },
|
const raw = await bffList<BffLetterSummary>(`/letters/location/${encoded}`, {
|
||||||
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
tags: ["letters"],
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
recipients: true,
|
|
||||||
recipientDisplay: true,
|
|
||||||
date: true,
|
|
||||||
year: true,
|
|
||||||
month: true,
|
|
||||||
location: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return letters;
|
return raw.map(mapLetterSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Single letter ---
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single letter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getLetter(slug: string): Promise<LetterFull | null> {
|
export async function getLetter(slug: string): Promise<LetterFull | null> {
|
||||||
const letter = await prisma.letter.findUnique({ where: { slug } });
|
const letter = await bff<BffLetterFull>(`/letters/${slug}`, {
|
||||||
|
tags: ["letters"],
|
||||||
|
});
|
||||||
if (!letter) return null;
|
if (!letter) return null;
|
||||||
|
return mapLetterFull(letter);
|
||||||
return {
|
|
||||||
slug: letter.slug,
|
|
||||||
recipients: letter.recipients,
|
|
||||||
recipientDisplay: letter.recipientDisplay,
|
|
||||||
date: letter.date,
|
|
||||||
dateRef: letter.dateRef,
|
|
||||||
year: letter.year,
|
|
||||||
month: letter.month,
|
|
||||||
day: letter.day,
|
|
||||||
location: letter.location,
|
|
||||||
salutation: letter.salutation,
|
|
||||||
paragraphs: letter.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Navigation helpers ---
|
// ---------------------------------------------------------------------------
|
||||||
|
// Navigation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getAdjacentLetters(
|
export async function getAdjacentLetters(
|
||||||
slug: string,
|
slug: string,
|
||||||
@@ -223,27 +246,18 @@ export async function getAdjacentLetters(
|
|||||||
): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> {
|
): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> {
|
||||||
if (!year) return { prev: null, next: null };
|
if (!year) return { prev: null, next: null };
|
||||||
|
|
||||||
const yearLetters = await prisma.letter.findMany({
|
const letters = await getLettersByYear(year);
|
||||||
where: { year },
|
const idx = letters.findIndex((l) => l.slug === slug);
|
||||||
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
recipients: true,
|
|
||||||
recipientDisplay: true,
|
|
||||||
date: true,
|
|
||||||
year: true,
|
|
||||||
month: true,
|
|
||||||
location: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const idx = yearLetters.findIndex((l) => l.slug === slug);
|
|
||||||
return {
|
return {
|
||||||
prev: idx > 0 ? yearLetters[idx - 1] : null,
|
prev: idx > 0 ? letters[idx - 1] : null,
|
||||||
next: idx < yearLetters.length - 1 ? yearLetters[idx + 1] : null,
|
next: idx < letters.length - 1 ? letters[idx + 1] : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTotalLetterCount(): Promise<number> {
|
export async function getTotalLetterCount(): Promise<number> {
|
||||||
return prisma.letter.count();
|
const result = await bff<{ count: number }>("/letters", {
|
||||||
|
tags: ["letters"],
|
||||||
|
});
|
||||||
|
return result?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-416
@@ -1,429 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Light of the Bhāgavata (LoB).
|
* Data access layer for Light of the Bhāgavata (LOB).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* LoB is a prose book with no volume structure —
|
|
||||||
* we use volume=0 as the sentinel for "no volumes".
|
|
||||||
*
|
|
||||||
* It has 48 "texts" (numbered chapters) plus 4 intro segments
|
|
||||||
* (front-pages, preface, acknowledgement, the-artist).
|
|
||||||
*
|
|
||||||
* When the DB isn't seeded yet, falls back to reading local JSON files
|
|
||||||
* from lib/jsons/books/lob/.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
import fs from "fs";
|
getIntroSegments,
|
||||||
import path from "path";
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "lob";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export type LobMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type LobChapterEntry = { number: number; title: string };
|
||||||
|
export type LobIntroSegment = IntroSegment;
|
||||||
|
export type LobChapter = Chapter;
|
||||||
|
export type LobSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "lob";
|
export async function getLobMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
const LOB_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "lob");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type LobMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: LobChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LobChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LobIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LobChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type LobSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "48", etc. */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/api.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getLobIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getLobIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getLobChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getLobContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
export async function getLobSidebarItems() {
|
||||||
|
return getSidebarItems(CODE);
|
||||||
function getLocalIntroSegments(): LobIntroSegment[] {
|
|
||||||
const introDir = path.join(LOB_JSON_DIR, "intro");
|
|
||||||
if (!fs.existsSync(introDir)) return [];
|
|
||||||
|
|
||||||
const results: LobIntroSegment[] = [];
|
|
||||||
const files = [
|
|
||||||
"front-pages.json",
|
|
||||||
"preface.json",
|
|
||||||
"acknowledgement.json",
|
|
||||||
"the-artist.json",
|
|
||||||
];
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(introDir, file);
|
|
||||||
const seg = readLocalJson<LobIntroSegment>(filePath);
|
|
||||||
if (seg) results.push(seg);
|
|
||||||
}
|
|
||||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalIntroSegment(rawSlug: string): LobIntroSegment | null {
|
|
||||||
const filePath = path.join(LOB_JSON_DIR, "intro", `${rawSlug}.json`);
|
|
||||||
return readLocalJson<LobIntroSegment>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): LobChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
LOB_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<LobChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getLobMeta(): Promise<LobMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as LobMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(LOB_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<LobMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getLobIntroSegments(): Promise<LobIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
return getLocalIntroSegments();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getLobIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<LobIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalIntroSegment(rawSlug);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
slug: dbSlug,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters (Texts 1–48)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered text/chapter (1–48).
|
|
||||||
*/
|
|
||||||
export async function getLobChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<LobChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 48 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalChapter(chapterNum);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "intro-preface" → intro segment
|
|
||||||
* "intro-acknowledgement" → intro segment
|
|
||||||
* "intro-the-artist" → intro segment
|
|
||||||
* "1" … "48" → chapter (text)
|
|
||||||
*/
|
|
||||||
export async function getLobContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<LobChapter | LobIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getLobIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 48) {
|
|
||||||
return getLobChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → preface → acknowledgement → the-artist → texts 1–48
|
|
||||||
*/
|
|
||||||
export async function getLobSidebarItems(): Promise<LobSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getLobMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true, order: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If we got DB results, build from those
|
|
||||||
if (meta && allDocs.length > 0) {
|
|
||||||
return buildSidebarFromDocs(meta, allDocs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: build from local JSON
|
|
||||||
return buildSidebarFromLocal(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromDocs(
|
|
||||||
meta: LobMeta,
|
|
||||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
|
||||||
): LobSidebarItem[] {
|
|
||||||
const items: LobSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (all come before chapters; LoB has no back matter)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Texts 1–48 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Text ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: LobMeta | null): LobSidebarItem[] {
|
|
||||||
const items: LobSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Local intro segments (front matter only — LoB has no back matter)
|
|
||||||
const introSegs = getLocalIntroSegments();
|
|
||||||
for (const seg of introSegs) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Texts from meta or generate default 1-48
|
|
||||||
if (meta && meta.chapters) {
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Text ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: scan local chapter JSONs
|
|
||||||
const chaptersDir = path.join(LOB_JSON_DIR, "chapters");
|
|
||||||
if (fs.existsSync(chaptersDir)) {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(chaptersDir)
|
|
||||||
.filter((f) => f.endsWith(".json"))
|
|
||||||
.sort();
|
|
||||||
for (const file of files) {
|
|
||||||
const ch = readLocalJson<LobChapter>(path.join(chaptersDir, file));
|
|
||||||
if (ch) {
|
|
||||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
|
||||||
items.push({
|
|
||||||
slug: String(num),
|
|
||||||
label: `Text ${num}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: num,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-38
@@ -1,13 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Magazines (Back to Godhead, etc.).
|
* Data access layer for Magazines (Back to Godhead, etc.).
|
||||||
*
|
*
|
||||||
* Uses dedicated Prisma models:
|
* Uses the BFF API instead of direct Prisma queries.
|
||||||
* - MagazineMeta (collection: magazine_meta)
|
* Falls back to local JSON files when BFF is unavailable.
|
||||||
* - MagazineIssue (collection: magazine_issues)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { bff, bffList } from "@/lib/bff";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
@@ -57,6 +55,35 @@ export type MagazineSidebarItem = {
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- BFF response types ---
|
||||||
|
|
||||||
|
type BffMagazineMeta = {
|
||||||
|
id: string;
|
||||||
|
book: string;
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
year: number | null;
|
||||||
|
description: string | null;
|
||||||
|
totalIssues: number;
|
||||||
|
totalArticles: number;
|
||||||
|
flag: string | null;
|
||||||
|
chapters: MagazineChapterEntry[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type BffMagazineIssue = {
|
||||||
|
book: string;
|
||||||
|
volume: number | null;
|
||||||
|
part: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
paragraphs: string[];
|
||||||
|
paragraphs_json?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- HTML sanitisation ---
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
function sanitizeHtml(html: string): string {
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
||||||
@@ -71,7 +98,7 @@ function sanitizeHtml(html: string): string {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
function normalizeParagraphs(paragraphs: string[] | unknown): string[] {
|
||||||
let result: string[];
|
let result: string[];
|
||||||
if (typeof paragraphs === "string") {
|
if (typeof paragraphs === "string") {
|
||||||
try {
|
try {
|
||||||
@@ -88,6 +115,57 @@ function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|||||||
return result.map(sanitizeHtml);
|
return result.map(sanitizeHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- BFF → domain mappers ---
|
||||||
|
|
||||||
|
function mapBffMetaToMagazineMetaData(raw: BffMagazineMeta): MagazineMetaData {
|
||||||
|
return {
|
||||||
|
book: raw.book,
|
||||||
|
title: raw.data.title,
|
||||||
|
author: raw.data.author,
|
||||||
|
year: raw.data.year ?? null,
|
||||||
|
description: raw.data.description ?? null,
|
||||||
|
totalIssues: raw.data.totalIssues,
|
||||||
|
totalArticles: raw.data.totalArticles,
|
||||||
|
flag: raw.data.flag ?? null,
|
||||||
|
chapters: raw.data.chapters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBffMetaToSummary(raw: BffMagazineMeta): MagazineSummary {
|
||||||
|
return {
|
||||||
|
book: raw.book,
|
||||||
|
title: raw.data.title,
|
||||||
|
author: raw.data.author,
|
||||||
|
year: raw.data.year ?? null,
|
||||||
|
description: raw.data.description ?? null,
|
||||||
|
totalIssues: raw.data.totalIssues,
|
||||||
|
flag: raw.data.flag ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBffIssue(raw: BffMagazineIssue): MagazineIssueData {
|
||||||
|
// Handle paragraphs_json fallback
|
||||||
|
let paragraphs = raw.paragraphs;
|
||||||
|
if ((!paragraphs || !paragraphs.length) && raw.paragraphs_json) {
|
||||||
|
try {
|
||||||
|
paragraphs = JSON.parse(raw.paragraphs_json);
|
||||||
|
} catch {
|
||||||
|
paragraphs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
book: raw.book,
|
||||||
|
volume: raw.volume,
|
||||||
|
part: raw.part,
|
||||||
|
title: raw.title,
|
||||||
|
slug: raw.slug,
|
||||||
|
paragraphs: normalizeParagraphs(paragraphs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Local JSON fallback helpers ---
|
||||||
|
|
||||||
function findLocalDirForBook(bookCode: string): string | null {
|
function findLocalDirForBook(bookCode: string): string | null {
|
||||||
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
|
if (!fs.existsSync(LOCAL_JSON_DIR)) return null;
|
||||||
const entries = fs.readdirSync(LOCAL_JSON_DIR);
|
const entries = fs.readdirSync(LOCAL_JSON_DIR);
|
||||||
@@ -152,22 +230,9 @@ function getLocalIssue(
|
|||||||
|
|
||||||
export async function getAllMagazines(): Promise<MagazineSummary[]> {
|
export async function getAllMagazines(): Promise<MagazineSummary[]> {
|
||||||
try {
|
try {
|
||||||
const results = await prisma.magazineMeta.findMany({
|
const results = await bffList<BffMagazineMeta>("/magazines");
|
||||||
orderBy: { book: "asc" },
|
|
||||||
});
|
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
return results.map((row) => {
|
return results.map(mapBffMetaToSummary);
|
||||||
const data = row.data as unknown as MagazineMetaData;
|
|
||||||
return {
|
|
||||||
book: row.book,
|
|
||||||
title: data.title,
|
|
||||||
author: data.author,
|
|
||||||
year: data.year ?? null,
|
|
||||||
description: data.description ?? null,
|
|
||||||
totalIssues: data.totalIssues,
|
|
||||||
flag: data.flag ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
@@ -188,10 +253,8 @@ export async function getMagazineMeta(
|
|||||||
bookCode: string,
|
bookCode: string,
|
||||||
): Promise<MagazineMetaData | null> {
|
): Promise<MagazineMetaData | null> {
|
||||||
try {
|
try {
|
||||||
const row = await prisma.magazineMeta.findUnique({
|
const raw = await bff<BffMagazineMeta>(`/magazines/${bookCode}`);
|
||||||
where: { book: bookCode },
|
if (raw) return mapBffMetaToMagazineMetaData(raw);
|
||||||
});
|
|
||||||
if (row) return row.data as unknown as MagazineMetaData;
|
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
}
|
}
|
||||||
@@ -204,19 +267,10 @@ export async function getMagazineIssue(
|
|||||||
): Promise<MagazineIssueData | null> {
|
): Promise<MagazineIssueData | null> {
|
||||||
if (!slug) return null;
|
if (!slug) return null;
|
||||||
try {
|
try {
|
||||||
const doc = await prisma.magazineIssue.findUnique({
|
const raw = await bff<BffMagazineIssue>(
|
||||||
where: { book_slug: { book: bookCode, slug } },
|
`/magazines/${bookCode}/issues/${slug}`,
|
||||||
});
|
);
|
||||||
if (doc) {
|
if (raw) return mapBffIssue(raw);
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
volume: doc.volume,
|
|
||||||
part: doc.part,
|
|
||||||
title: doc.title,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,280 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Kṛṣṇa Consciousness: The Matchless Gift (MG).
|
* Data access layer for Kṛṣṇa Consciousness: The Matchless Gift (MG).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* MG is a simple prose book with 1 intro segment (front-pages) and 7 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "mg";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "mg";
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
|
export type MgMeta = BookMeta;
|
||||||
|
export type MgChapterEntry = { number: number; title: string };
|
||||||
|
export type MgIntroSegment = IntroSegment;
|
||||||
|
export type MgChapter = Chapter;
|
||||||
|
export type MgSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type MgMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: MgChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MgChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MgIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MgChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type MgSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "7" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getMgMeta(): Promise<MgMeta | null> {
|
export async function getMgMeta(): Promise<MgMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
return getBookMeta(CODE);
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as MgMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getMgIntroSegments(): Promise<MgIntroSegment[]> {
|
export async function getMgIntroSegments(): Promise<MgIntroSegment[]> {
|
||||||
const results = await prisma.proseChapter.findMany({
|
return getIntroSegments(CODE);
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getMgIntroSegment(
|
export async function getMgIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<MgIntroSegment | null> {
|
): Promise<MgIntroSegment | null> {
|
||||||
// DB stores with "intro-" prefix; accept either form
|
return getIntroSegment(CODE, slug);
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–7).
|
|
||||||
*/
|
|
||||||
export async function getMgChapter(
|
export async function getMgChapter(
|
||||||
chapterNum: number,
|
chapterNum: number,
|
||||||
): Promise<MgChapter | null> {
|
): Promise<MgChapter | null> {
|
||||||
if (chapterNum < 1 || chapterNum > 7 || !Number.isInteger(chapterNum)) {
|
return getChapter(CODE, String(chapterNum));
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "7" → chapter
|
|
||||||
*/
|
|
||||||
export async function getMgContent(
|
export async function getMgContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<MgChapter | MgIntroSegment | null> {
|
): Promise<MgChapter | MgIntroSegment | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
return getContent(CODE, slug);
|
||||||
return getMgIntroSegment(slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 7) {
|
|
||||||
return getMgChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–7
|
|
||||||
*/
|
|
||||||
export async function getMgSidebarItems(): Promise<MgSidebarItem[]> {
|
export async function getMgSidebarItems(): Promise<MgSidebarItem[]> {
|
||||||
const [meta, allDocs] = await Promise.all([
|
return getSidebarItems(CODE);
|
||||||
getMgMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: MgSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–7 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-416
@@ -1,430 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Message of Godhead (MOG).
|
* Data access layer for Message of Godhead (MOG).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* MOG is a prose book with no volume structure —
|
|
||||||
* we use volume=0 as the sentinel for "no volumes".
|
|
||||||
*
|
|
||||||
* When the DB isn't seeded yet, falls back to reading local JSON files
|
|
||||||
* from lib/jsons/books/mog/.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
import fs from "fs";
|
getIntroSegments,
|
||||||
import path from "path";
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "mog";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export type MogMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type MogChapterEntry = { number: number; title: string };
|
||||||
|
export type MogIntroSegment = IntroSegment;
|
||||||
|
export type MogChapter = Chapter;
|
||||||
|
export type MogSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "mog";
|
export async function getMogMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
const MOG_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "mog");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type MogMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: MogChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MogChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MogIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MogChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type MogSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "2", "3" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/api.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getMogIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getMogIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getMogChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getMogContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
export async function getMogSidebarItems() {
|
||||||
|
return getSidebarItems(CODE);
|
||||||
function getLocalIntroSegments(): MogIntroSegment[] {
|
|
||||||
const introDir = path.join(MOG_JSON_DIR, "intro");
|
|
||||||
if (!fs.existsSync(introDir)) return [];
|
|
||||||
|
|
||||||
const results: MogIntroSegment[] = [];
|
|
||||||
const files = ["front-pages.json"];
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(introDir, file);
|
|
||||||
const seg = readLocalJson<MogIntroSegment>(filePath);
|
|
||||||
if (seg) results.push(seg);
|
|
||||||
}
|
|
||||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalIntroSegment(rawSlug: string): MogIntroSegment | null {
|
|
||||||
const filePath = path.join(MOG_JSON_DIR, "intro", `${rawSlug}.json`);
|
|
||||||
return readLocalJson<MogIntroSegment>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): MogChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
MOG_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<MogChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getMogMeta(): Promise<MogMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as MogMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(MOG_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<MogMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getMogIntroSegments(): Promise<MogIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
return getLocalIntroSegments();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getMogIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<MogIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
const rawSlug = slug.startsWith("intro-") ? slug.replace(/^intro-/, "") : slug;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalIntroSegment(rawSlug);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
slug: dbSlug,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–3).
|
|
||||||
*/
|
|
||||||
export async function getMogChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<MogChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 3 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalChapter(chapterNum);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "3" → chapter
|
|
||||||
*/
|
|
||||||
export async function getMogContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<MogChapter | MogIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getMogIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 3) {
|
|
||||||
return getMogChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–3
|
|
||||||
*/
|
|
||||||
export async function getMogSidebarItems(): Promise<MogSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getMogMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
title: true,
|
|
||||||
type: true,
|
|
||||||
chapter: true,
|
|
||||||
order: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If we got DB results, build from those
|
|
||||||
if (meta && allDocs.length > 0) {
|
|
||||||
return buildSidebarFromDocs(meta, allDocs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: build from local JSON
|
|
||||||
return buildSidebarFromLocal(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromDocs(
|
|
||||||
meta: MogMeta,
|
|
||||||
allDocs: {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
chapter: number | null;
|
|
||||||
order: number | null;
|
|
||||||
}[],
|
|
||||||
): MogSidebarItem[] {
|
|
||||||
const items: MogSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (front-pages)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–3 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: MogMeta | null): MogSidebarItem[] {
|
|
||||||
const items: MogSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Local intro segments (front matter)
|
|
||||||
const introSegs = getLocalIntroSegments();
|
|
||||||
for (const seg of introSegs) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters from meta or scan local chapter JSONs
|
|
||||||
if (meta && meta.chapters) {
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: scan local chapter JSONs
|
|
||||||
const chaptersDir = path.join(MOG_JSON_DIR, "chapters");
|
|
||||||
if (fs.existsSync(chaptersDir)) {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(chaptersDir)
|
|
||||||
.filter((f) => f.endsWith(".json"))
|
|
||||||
.sort();
|
|
||||||
for (const file of files) {
|
|
||||||
const ch = readLocalJson<MogChapter>(path.join(chaptersDir, file));
|
|
||||||
if (ch) {
|
|
||||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
|
||||||
items.push({
|
|
||||||
slug: String(num),
|
|
||||||
label: `${num}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: num,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-317
@@ -1,332 +1,49 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for The Nectar of Devotion (NOD).
|
* Data access layer for The Nectar of Devotion (NOD).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* NoD is a prose book (like KB) but has no volume structure —
|
|
||||||
* we use volume=0 as the sentinel for "no volumes".
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "nod";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export type NodMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type NodChapterEntry = { number: number; title: string };
|
||||||
|
export type NodIntroSegment = IntroSegment;
|
||||||
|
export type NodChapter = Chapter;
|
||||||
|
export type NodSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "nod";
|
export async function getNodMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type NodMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
parts: NodPart[];
|
|
||||||
chapters: NodChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NodPart = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
chapters: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NodChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NodIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NodChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type NodSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-flap", "1", "51", "concluding-words" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" | "conclusion" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/api.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getNodIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getNodIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getNodChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
// NodConcludingWords is just a chapter with slug "concluding-words"
|
||||||
// ---------------------------------------------------------------------------
|
export async function getNodConcludingWords() {
|
||||||
// Book meta
|
return getChapter(CODE, "concluding-words");
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, parts, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getNodMeta(): Promise<NodMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as NodMeta;
|
|
||||||
}
|
}
|
||||||
|
export async function getNodContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getNodIntroSegments(): Promise<NodIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
export async function getNodSidebarItems() {
|
||||||
/**
|
return getSidebarItems(CODE);
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-preface" (URL slug) and "preface" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getNodIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<NodIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–51).
|
|
||||||
*/
|
|
||||||
export async function getNodChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<NodChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 51 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The concluding words section.
|
|
||||||
*/
|
|
||||||
export async function getNodConcludingWords(): Promise<NodChapter | null> {
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: "concluding-words",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-flap" → intro segment
|
|
||||||
* "intro-preface" → intro segment
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "1" … "51" → chapter
|
|
||||||
* "concluding-words" → concluding section
|
|
||||||
*/
|
|
||||||
export async function getNodContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<NodChapter | NodIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getNodIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug === "concluding-words") {
|
|
||||||
return getNodConcludingWords();
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 51) {
|
|
||||||
return getNodChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: intro segments → chapters 1–51 → concluding words
|
|
||||||
*/
|
|
||||||
export async function getNodSidebarItems(): Promise<NodSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getNodMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: NodSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–51 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `Chapter ${ch.number}: ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Concluding words
|
|
||||||
const cw = allDocs.find((d) => d.type === "conclusion");
|
|
||||||
if (cw) {
|
|
||||||
items.push({
|
|
||||||
slug: cw.slug,
|
|
||||||
label: cw.title,
|
|
||||||
type: "conclusion",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-278
@@ -1,322 +1,89 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for The Nectar of Instruction (NoI).
|
* Data access layer for The Nectar of Instruction (NoI).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer (BFF API) for prose intro segments,
|
||||||
* NoI has 11 texts (verses) plus intro segments (front-pages, preface, appendixes).
|
* and to lib/api.ts for verse (text) lookups.
|
||||||
*
|
*
|
||||||
* Verses were seeded with:
|
* NoI has 11 texts (verses) plus intro segments.
|
||||||
* - book: "noi", division: null
|
* Verses are stored with: book "noi", division null, chapter = text number, verse = 1.
|
||||||
* - chapter = text number (1-11)
|
|
||||||
* - verse = always 1
|
|
||||||
*
|
|
||||||
* Intro segments were seeded with:
|
|
||||||
* - book: "noi", volume: 0, type: "intro-segment"
|
|
||||||
* - slugs like "intro-front-pages", "intro-preface", "intro-appendixes"
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
import { getSingleVerse, type VerseRecord } from "@/lib/api";
|
||||||
|
|
||||||
|
const CODE = "noi";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const BOOK = "noi";
|
export type NoiMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type NoiIntroSegment = IntroSegment;
|
||||||
|
export type NoiChapter = Chapter;
|
||||||
// ---------------------------------------------------------------------------
|
export type NoiSidebarItem = SidebarItem;
|
||||||
// Text title lookup
|
export type NoiVerse = VerseRecord;
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const TEXT_TITLES: Record<number, string> = {
|
|
||||||
1: "Text One",
|
|
||||||
2: "Text Two",
|
|
||||||
3: "Text Three",
|
|
||||||
4: "Text Four",
|
|
||||||
5: "Text Five",
|
|
||||||
6: "Text Six",
|
|
||||||
7: "Text Seven",
|
|
||||||
8: "Text Eight",
|
|
||||||
9: "Text Nine",
|
|
||||||
10: "Text Ten",
|
|
||||||
11: "Text Eleven",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type NoiMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalTexts: number;
|
|
||||||
texts: NoiTextEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NoiTextEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NoiIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NoiVerse = {
|
|
||||||
book: string;
|
|
||||||
book_title: string;
|
|
||||||
text: number;
|
|
||||||
title: string;
|
|
||||||
reference: string;
|
|
||||||
devanagari: string;
|
|
||||||
transliteration: string;
|
|
||||||
synonyms: { word: string; meanings: string[] }[];
|
|
||||||
translation: string;
|
|
||||||
purport: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type NoiSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "intro-preface", "1", etc. */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "verse" */
|
|
||||||
type: string;
|
|
||||||
/** Text number (1-11), or null for non-verse items */
|
|
||||||
text: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/iso.ts, lib/nod.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize purport via normalizeParagraphs. */
|
|
||||||
function normalizePurport(purport: string[] | JsonValue): string[] {
|
|
||||||
return normalizeParagraphs(purport);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Book meta
|
// Book meta
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
export async function getNoiMeta() {
|
||||||
* Book-level metadata (title, author, texts list, etc.)
|
return getBookMeta(CODE);
|
||||||
*/
|
|
||||||
export async function getNoiMeta(): Promise<NoiMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as NoiMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Intro segments (from prose_chapters collection)
|
// Intro segments
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
export async function getNoiIntroSegments() {
|
||||||
* All intro segments, ordered by their `order` field.
|
return getIntroSegments(CODE);
|
||||||
*/
|
|
||||||
export async function getNoiIntroSegments(): Promise<NoiIntroSegment[]> {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getNoiIntroSegment(slug: string) {
|
||||||
* A single intro segment by slug.
|
return getIntroSegment(CODE, slug);
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getNoiIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<NoiIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verses (texts 1-11, from verses collection)
|
// Verses (texts 1-11, from verses collection via BFF)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* A single verse/text by slug.
|
|
||||||
*
|
|
||||||
* - "1"-"11" → chapter=1–11, verse=1
|
|
||||||
*/
|
|
||||||
export async function getNoiVerse(slug: string): Promise<NoiVerse | null> {
|
export async function getNoiVerse(slug: string): Promise<NoiVerse | null> {
|
||||||
const textNum = Number(slug);
|
const num = parseInt(slug, 10);
|
||||||
if (!Number.isInteger(textNum) || textNum < 1 || textNum > 11) return null;
|
if (isNaN(num)) return null;
|
||||||
|
return getSingleVerse(CODE, null, num, 1);
|
||||||
const doc = await prisma.verse.findFirst({
|
|
||||||
where: { book: BOOK, division: null, chapter: textNum, verse: 1 },
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
book_title: doc.bookTitle,
|
|
||||||
text: doc.chapter, // chapter field stores the text number
|
|
||||||
title: TEXT_TITLES[doc.chapter] ?? `Text ${doc.chapter}`,
|
|
||||||
reference: doc.reference,
|
|
||||||
devanagari: doc.devanagari,
|
|
||||||
transliteration: doc.transliteration,
|
|
||||||
synonyms: doc.synonyms as { word: string; meanings: string[] }[],
|
|
||||||
translation: sanitizeHtml(doc.translation),
|
|
||||||
purport: normalizePurport(doc.purport),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unified content accessor
|
// Unified content accessor
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "intro-preface" → intro segment
|
|
||||||
* "intro-appendixes" → intro segment
|
|
||||||
* "1" … "11" → verse/text
|
|
||||||
*/
|
|
||||||
export async function getNoiContent(
|
export async function getNoiContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<NoiVerse | NoiIntroSegment | null> {
|
): Promise<NoiVerse | NoiIntroSegment | NoiChapter | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
// Try as verse first (numeric slug)
|
||||||
return getNoiIntroSegment(slug);
|
if (/^\d+$/.test(slug)) {
|
||||||
|
const verse = await getNoiVerse(slug);
|
||||||
|
if (verse) return verse;
|
||||||
}
|
}
|
||||||
|
// Fall back to prose content (intro segments, etc.)
|
||||||
const num = Number(slug);
|
return getContent(CODE, slug);
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
|
|
||||||
return getNoiVerse(String(num));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar navigation
|
// Sidebar navigation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
export async function getNoiSidebarItems() {
|
||||||
* Build the full sidebar navigation list.
|
return getSidebarItems(CODE);
|
||||||
*
|
|
||||||
* Order: front-pages → preface → texts 1-11 → appendixes
|
|
||||||
*/
|
|
||||||
export async function getNoiSidebarItems(): Promise<NoiSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getNoiMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: NoiSidebarItem[] = [];
|
|
||||||
|
|
||||||
// 1. Intro segments that come before the texts (front-pages, preface — by order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && doc.slug !== "intro-appendixes") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
text: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Texts 1-11 (from meta.texts)
|
|
||||||
for (const t of meta.texts) {
|
|
||||||
items.push({
|
|
||||||
slug: String(t.number),
|
|
||||||
label: `${t.number}. ${t.title}`,
|
|
||||||
type: "verse",
|
|
||||||
text: t.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Appendixes intro segment (at the end)
|
|
||||||
const appendixes = allDocs.find((d) => d.slug === "intro-appendixes");
|
|
||||||
if (appendixes) {
|
|
||||||
items.push({
|
|
||||||
slug: appendixes.slug,
|
|
||||||
label: appendixes.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
text: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-250
@@ -1,280 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for On Chanting the Hare Kṛṣṇa Mantra (OCHKM).
|
* Data access layer for On Chanting Hare Kṛṣṇa (OCHKM).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* OCHKM is a prose book with 1 intro segment (front-pages) and 4 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "ochkm";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "ochkm";
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
|
export type OchkmMeta = BookMeta;
|
||||||
|
export type OchkmChapterEntry = { number: number; title: string };
|
||||||
|
export type OchkmIntroSegment = IntroSegment;
|
||||||
|
export type OchkmChapter = Chapter;
|
||||||
|
export type OchkmSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type OchkmMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: OchkmChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OchkmChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OchkmIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OchkmChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type OchkmSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "4" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getOchkmMeta(): Promise<OchkmMeta | null> {
|
export async function getOchkmMeta(): Promise<OchkmMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
return getBookMeta(CODE);
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as OchkmMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getOchkmIntroSegments(): Promise<OchkmIntroSegment[]> {
|
export async function getOchkmIntroSegments(): Promise<OchkmIntroSegment[]> {
|
||||||
const results = await prisma.proseChapter.findMany({
|
return getIntroSegments(CODE);
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getOchkmIntroSegment(
|
export async function getOchkmIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<OchkmIntroSegment | null> {
|
): Promise<OchkmIntroSegment | null> {
|
||||||
// DB stores with "intro-" prefix; accept either form
|
return getIntroSegment(CODE, slug);
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–4).
|
|
||||||
*/
|
|
||||||
export async function getOchkmChapter(
|
export async function getOchkmChapter(
|
||||||
chapterNum: number,
|
chapterNum: number,
|
||||||
): Promise<OchkmChapter | null> {
|
): Promise<OchkmChapter | null> {
|
||||||
if (chapterNum < 1 || chapterNum > 4 || !Number.isInteger(chapterNum)) {
|
return getChapter(CODE, String(chapterNum));
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "4" → chapter
|
|
||||||
*/
|
|
||||||
export async function getOchkmContent(
|
export async function getOchkmContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<OchkmChapter | OchkmIntroSegment | null> {
|
): Promise<OchkmChapter | OchkmIntroSegment | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
return getContent(CODE, slug);
|
||||||
return getOchkmIntroSegment(slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 4) {
|
|
||||||
return getOchkmChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–4
|
|
||||||
*/
|
|
||||||
export async function getOchkmSidebarItems(): Promise<OchkmSidebarItem[]> {
|
export async function getOchkmSidebarItems(): Promise<OchkmSidebarItem[]> {
|
||||||
const [meta, allDocs] = await Promise.all([
|
return getSidebarItems(CODE);
|
||||||
getOchkmMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: OchkmSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–4 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-54
@@ -1,18 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Other Documents.
|
* Data access layer for Other Documents.
|
||||||
*
|
*
|
||||||
* Uses dedicated Prisma models:
|
* Calls the BFF API for all data access (no direct Prisma usage).
|
||||||
* - OtherDocumentMeta (collection: other_document_meta)
|
|
||||||
* - OtherDocumentChapter (collection: other_document_chapters)
|
|
||||||
*
|
|
||||||
* Falls back to reading local JSON files from
|
* Falls back to reading local JSON files from
|
||||||
* lib/jsons/other-documents/<slug>/ when the database is unavailable.
|
* lib/jsons/other-documents/<slug>/ when the BFF is unavailable.
|
||||||
*
|
*
|
||||||
* Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula").
|
* Book codes are plain slugs (e.g. "analysis-bg", "real-peace-formula").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { bff, bffList } from "@/lib/bff";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
@@ -72,6 +68,33 @@ export type OtherDocumentSidebarItem = {
|
|||||||
chapter: number;
|
chapter: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BFF response types (snake_case from Go)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type BffDocumentMeta = {
|
||||||
|
id: string;
|
||||||
|
book: string;
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
year?: number | null;
|
||||||
|
description?: string | null;
|
||||||
|
totalChapters: number;
|
||||||
|
chapters: { number: number; title: string }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type BffChapter = {
|
||||||
|
id: string;
|
||||||
|
book: string;
|
||||||
|
chapter: number;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
slug: string;
|
||||||
|
paragraphs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HTML sanitisation (same pattern used across all readers)
|
// HTML sanitisation (same pattern used across all readers)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -90,23 +113,52 @@ function sanitizeHtml(html: string): string {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
// ---------------------------------------------------------------------------
|
||||||
let result: string[];
|
// Mappers (BFF response → frontend types)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
function mapMetaData(raw: BffDocumentMeta): OtherDocumentMetaData {
|
||||||
try {
|
const data =
|
||||||
const parsed = JSON.parse(paragraphs);
|
raw.data && typeof raw.data === "object"
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
? raw.data
|
||||||
} catch {
|
: ({} as BffDocumentMeta["data"]);
|
||||||
result = [paragraphs];
|
return {
|
||||||
}
|
book: raw.book,
|
||||||
} else if (Array.isArray(paragraphs)) {
|
title: data.title ?? "",
|
||||||
result = paragraphs as string[];
|
author: data.author ?? "",
|
||||||
} else {
|
year: data.year ?? null,
|
||||||
result = [];
|
description: data.description ?? null,
|
||||||
|
totalChapters: data.totalChapters ?? 0,
|
||||||
|
chapters: (data.chapters ?? []).map((ch) => ({
|
||||||
|
number: ch.number,
|
||||||
|
title: ch.title,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
function mapSummary(raw: BffDocumentMeta): OtherDocumentSummary {
|
||||||
|
const data =
|
||||||
|
raw.data && typeof raw.data === "object"
|
||||||
|
? raw.data
|
||||||
|
: ({} as BffDocumentMeta["data"]);
|
||||||
|
return {
|
||||||
|
book: raw.book,
|
||||||
|
title: data.title ?? "",
|
||||||
|
author: data.author ?? "",
|
||||||
|
year: data.year ?? null,
|
||||||
|
description: data.description ?? null,
|
||||||
|
totalChapters: data.totalChapters ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChapter(raw: BffChapter): OtherDocumentChapterData {
|
||||||
|
return {
|
||||||
|
book: raw.book,
|
||||||
|
chapter: raw.chapter,
|
||||||
|
title: raw.title,
|
||||||
|
type: raw.type,
|
||||||
|
paragraphs: (raw.paragraphs ?? []).map(sanitizeHtml),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -195,22 +247,12 @@ function getLocalChapter(
|
|||||||
*/
|
*/
|
||||||
export async function getAllOtherDocuments(): Promise<OtherDocumentSummary[]> {
|
export async function getAllOtherDocuments(): Promise<OtherDocumentSummary[]> {
|
||||||
try {
|
try {
|
||||||
const results = await prisma.otherDocumentMeta.findMany({
|
const results = await bffList<BffDocumentMeta>("/other-documents", {
|
||||||
orderBy: { book: "asc" },
|
tags: ["other-documents"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
return results.map((row) => {
|
return results.map(mapSummary);
|
||||||
const data = row.data as unknown as OtherDocumentMetaData;
|
|
||||||
return {
|
|
||||||
book: row.book,
|
|
||||||
title: data.title,
|
|
||||||
author: data.author,
|
|
||||||
year: data.year ?? null,
|
|
||||||
description: data.description ?? null,
|
|
||||||
totalChapters: data.totalChapters,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to local JSON
|
// fall through to local JSON
|
||||||
@@ -234,12 +276,12 @@ export async function getOtherDocumentMeta(
|
|||||||
bookCode: string,
|
bookCode: string,
|
||||||
): Promise<OtherDocumentMetaData | null> {
|
): Promise<OtherDocumentMetaData | null> {
|
||||||
try {
|
try {
|
||||||
const row = await prisma.otherDocumentMeta.findUnique({
|
const raw = await bff<BffDocumentMeta>(`/other-documents/${bookCode}`, {
|
||||||
where: { book: bookCode },
|
tags: [`other-doc-${bookCode}`],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (row) {
|
if (raw) {
|
||||||
return row.data as unknown as OtherDocumentMetaData;
|
return mapMetaData(raw);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to local JSON
|
// fall through to local JSON
|
||||||
@@ -258,23 +300,13 @@ export async function getOtherDocumentChapter(
|
|||||||
if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null;
|
if (chapterNum < 1 || !Number.isInteger(chapterNum)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = await prisma.otherDocumentChapter.findUnique({
|
const raw = await bff<BffChapter>(
|
||||||
where: {
|
`/other-documents/${bookCode}/chapters/${chapterNum}`,
|
||||||
book_slug: {
|
{ tags: [`other-doc-${bookCode}`] },
|
||||||
book: bookCode,
|
);
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
if (raw) {
|
||||||
return {
|
return mapChapter(raw);
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to local JSON
|
// fall through to local JSON
|
||||||
|
|||||||
+26
-249
@@ -1,280 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for On the Way to Kṛṣṇa (OWK).
|
* Data access layer for On the Way to Kṛṣṇa (OWK).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* OWK is a simple prose book with 1 intro segment (front-pages) and 5 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "owk";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "owk";
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
|
export type OwkMeta = BookMeta;
|
||||||
|
export type OwkChapterEntry = { number: number; title: string };
|
||||||
|
export type OwkIntroSegment = IntroSegment;
|
||||||
|
export type OwkChapter = Chapter;
|
||||||
|
export type OwkSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type OwkMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: OwkChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OwkChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OwkIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OwkChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type OwkSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "5" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getOwkMeta(): Promise<OwkMeta | null> {
|
export async function getOwkMeta(): Promise<OwkMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
return getBookMeta(CODE);
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as OwkMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getOwkIntroSegments(): Promise<OwkIntroSegment[]> {
|
export async function getOwkIntroSegments(): Promise<OwkIntroSegment[]> {
|
||||||
const results = await prisma.proseChapter.findMany({
|
return getIntroSegments(CODE);
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getOwkIntroSegment(
|
export async function getOwkIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<OwkIntroSegment | null> {
|
): Promise<OwkIntroSegment | null> {
|
||||||
// DB stores with "intro-" prefix; accept either form
|
return getIntroSegment(CODE, slug);
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–5).
|
|
||||||
*/
|
|
||||||
export async function getOwkChapter(
|
export async function getOwkChapter(
|
||||||
chapterNum: number,
|
chapterNum: number,
|
||||||
): Promise<OwkChapter | null> {
|
): Promise<OwkChapter | null> {
|
||||||
if (chapterNum < 1 || chapterNum > 5 || !Number.isInteger(chapterNum)) {
|
return getChapter(CODE, String(chapterNum));
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "5" → chapter
|
|
||||||
*/
|
|
||||||
export async function getOwkContent(
|
export async function getOwkContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<OwkChapter | OwkIntroSegment | null> {
|
): Promise<OwkChapter | OwkIntroSegment | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
return getContent(CODE, slug);
|
||||||
return getOwkIntroSegment(slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
|
||||||
return getOwkChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–5
|
|
||||||
*/
|
|
||||||
export async function getOwkSidebarItems(): Promise<OwkSidebarItem[]> {
|
export async function getOwkSidebarItems(): Promise<OwkSidebarItem[]> {
|
||||||
const [meta, allDocs] = await Promise.all([
|
return getSidebarItems(CODE);
|
||||||
getOwkMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: OwkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–5 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,306 +1,29 @@
|
|||||||
/**
|
import {
|
||||||
* Data access layer for Prayers of King Kulaśekhara (PK).
|
getBookMeta,
|
||||||
*
|
getChapter,
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
getContent,
|
||||||
* PK is a prose book with no volume structure and no intro segments —
|
getSidebarItems,
|
||||||
* we use volume=0 as the sentinel for "no volumes".
|
type BookMeta,
|
||||||
*
|
type Chapter,
|
||||||
* When the DB isn't seeded yet, falls back to reading local JSON files
|
type SidebarItem,
|
||||||
* from lib/jsons/books/pk/.
|
} from "@/lib/books";
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
const CODE = "pk";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export type PkMeta = BookMeta;
|
||||||
// Constants
|
export type PkChapterEntry = { number: number; title: string };
|
||||||
// ---------------------------------------------------------------------------
|
export type PkChapter = Chapter;
|
||||||
|
export type PkSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getPkMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
const BOOK = "pk";
|
|
||||||
|
|
||||||
const PK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "pk");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type PkMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: PkChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PkChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PkChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type PkSidebarItem = {
|
|
||||||
/** URL-safe slug: "1", "2", … "6" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/api.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
}
|
||||||
|
export async function getPkChapter(chapterNum: number) {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getChapter(CODE, String(chapterNum));
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getPkContent(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getContent(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getPkSidebarItems() {
|
||||||
return result.map(sanitizeHtml);
|
return getSidebarItems(CODE);
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): PkChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
PK_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<PkChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getPkMeta(): Promise<PkMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as PkMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(PK_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<PkMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–6).
|
|
||||||
*/
|
|
||||||
export async function getPkChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<PkChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 6 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalChapter(chapterNum);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "1" … "6" → chapter
|
|
||||||
*/
|
|
||||||
export async function getPkContent(slug: string): Promise<PkChapter | null> {
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 6) {
|
|
||||||
return getPkChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: chapters 1–6 (no intro segments for PK)
|
|
||||||
*/
|
|
||||||
export async function getPkSidebarItems(): Promise<PkSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getPkMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
title: true,
|
|
||||||
type: true,
|
|
||||||
chapter: true,
|
|
||||||
order: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If we got DB results, build from those
|
|
||||||
if (meta && allDocs.length > 0) {
|
|
||||||
return buildSidebarFromDocs(meta, allDocs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: build from local JSON
|
|
||||||
return buildSidebarFromLocal(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromDocs(
|
|
||||||
meta: PkMeta,
|
|
||||||
_allDocs: {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
chapter: number | null;
|
|
||||||
order: number | null;
|
|
||||||
}[],
|
|
||||||
): PkSidebarItem[] {
|
|
||||||
const items: PkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Chapters 1–6 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: PkMeta | null): PkSidebarItem[] {
|
|
||||||
const items: PkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Chapters from meta or scan local chapter JSONs
|
|
||||||
if (meta && meta.chapters) {
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: scan local chapter JSONs
|
|
||||||
const chaptersDir = path.join(PK_JSON_DIR, "chapters");
|
|
||||||
if (fs.existsSync(chaptersDir)) {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(chaptersDir)
|
|
||||||
.filter((f) => f.endsWith(".json"))
|
|
||||||
.sort();
|
|
||||||
for (const file of files) {
|
|
||||||
const ch = readLocalJson<PkChapter>(path.join(chaptersDir, file));
|
|
||||||
if (ch) {
|
|
||||||
const num = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
|
||||||
items.push({
|
|
||||||
slug: String(num),
|
|
||||||
label: `${num}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: num,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-249
@@ -1,280 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for The Perfection of Yoga (POY).
|
* Data access layer for The Perfection of Yoga (POY).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* POY is a prose book with 1 intro segment (front-pages) and 11 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "poy";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "poy";
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
|
export type PoyMeta = BookMeta;
|
||||||
|
export type PoyChapterEntry = { number: number; title: string };
|
||||||
|
export type PoyIntroSegment = IntroSegment;
|
||||||
|
export type PoyChapter = Chapter;
|
||||||
|
export type PoySidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type PoyMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: PoyChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PoyChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PoyIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PoyChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type PoySidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "11" */
|
|
||||||
slug: string;
|
|
||||||
/** Human-readable label for the sidebar */
|
|
||||||
label: string;
|
|
||||||
/** "intro-segment" | "chapter" */
|
|
||||||
type: string;
|
|
||||||
/** Chapter number, or null for non-chapter items */
|
|
||||||
chapter: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sanitizeHtml(html: string): string {
|
|
||||||
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
|
|
||||||
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
|
|
||||||
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
|
|
||||||
clean = clean.replace(
|
|
||||||
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
||||||
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
|
|
||||||
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(paragraphs)) {
|
|
||||||
result = paragraphs as string[];
|
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.map(sanitizeHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getPoyMeta(): Promise<PoyMeta | null> {
|
export async function getPoyMeta(): Promise<PoyMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
return getBookMeta(CODE);
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as PoyMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getPoyIntroSegments(): Promise<PoyIntroSegment[]> {
|
export async function getPoyIntroSegments(): Promise<PoyIntroSegment[]> {
|
||||||
const results = await prisma.proseChapter.findMany({
|
return getIntroSegments(CODE);
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getPoyIntroSegment(
|
export async function getPoyIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<PoyIntroSegment | null> {
|
): Promise<PoyIntroSegment | null> {
|
||||||
// DB stores with "intro-" prefix; accept either form
|
return getIntroSegment(CODE, slug);
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–11).
|
|
||||||
*/
|
|
||||||
export async function getPoyChapter(
|
export async function getPoyChapter(
|
||||||
chapterNum: number,
|
chapterNum: number,
|
||||||
): Promise<PoyChapter | null> {
|
): Promise<PoyChapter | null> {
|
||||||
if (chapterNum < 1 || chapterNum > 11 || !Number.isInteger(chapterNum)) {
|
return getChapter(CODE, String(chapterNum));
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "11" → chapter
|
|
||||||
*/
|
|
||||||
export async function getPoyContent(
|
export async function getPoyContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<PoyChapter | PoyIntroSegment | null> {
|
): Promise<PoyChapter | PoyIntroSegment | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
return getContent(CODE, slug);
|
||||||
return getPoyIntroSegment(slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 11) {
|
|
||||||
return getPoyChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–11
|
|
||||||
*/
|
|
||||||
export async function getPoySidebarItems(): Promise<PoySidebarItem[]> {
|
export async function getPoySidebarItems(): Promise<PoySidebarItem[]> {
|
||||||
const [meta, allDocs] = await Promise.all([
|
return getSidebarItems(CODE);
|
||||||
getPoyMeta(),
|
|
||||||
prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: { slug: true, title: true, type: true, chapter: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
const items: PoySidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments (from DB, in order)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment") {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–11 (use meta.chapters for consistent title/ordering)
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-322
@@ -1,336 +1,49 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Perfect Questions Perfect Answers (PQPA).
|
* Data access layer for Perfect Questions, Perfect Answers (PQPA).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* PQPA is a prose/dialogue book with no volume structure —
|
|
||||||
* we use volume=0 as the sentinel for "no volumes".
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "pqpa";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export type PqpaMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type PqpaChapterEntry = { number: number; title: string };
|
||||||
|
export type PqpaIntroSegment = IntroSegment;
|
||||||
|
export type PqpaChapter = Chapter;
|
||||||
|
export type PqpaSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "pqpa";
|
export async function getPqpaMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type PqpaMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: PqpaChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PqpaChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PqpaIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PqpaChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type PqpaSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "9", "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 getPqpaIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getPqpaIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getPqpaChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
// PqpaConcludingWords is just a chapter with slug "concluding-words"
|
||||||
// ---------------------------------------------------------------------------
|
export async function getPqpaConcludingWords() {
|
||||||
// Book meta
|
return getChapter(CODE, "concluding-words");
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getPqpaMeta(): Promise<PqpaMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as PqpaMeta;
|
|
||||||
}
|
}
|
||||||
|
export async function getPqpaContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getPqpaIntroSegments(): Promise<PqpaIntroSegment[]> {
|
|
||||||
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 getPqpaSidebarItems() {
|
||||||
/**
|
return getSidebarItems(CODE);
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-introduction" (URL slug) and "introduction" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getPqpaIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<PqpaIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–9).
|
|
||||||
*/
|
|
||||||
export async function getPqpaChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<PqpaChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 9 || !Number.isInteger(chapterNum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The concluding words section.
|
|
||||||
*/
|
|
||||||
export async function getPqpaConcludingWords(): Promise<PqpaChapter | 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-pages" → intro segment
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "intro-about-the-author" → intro segment
|
|
||||||
* "1" … "9" → chapter
|
|
||||||
* "concluding-words" → concluding section
|
|
||||||
*/
|
|
||||||
export async function getPqpaContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<PqpaChapter | PqpaIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getPqpaIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug === "concluding-words") {
|
|
||||||
return getPqpaConcludingWords();
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 9) {
|
|
||||||
return getPqpaChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → introduction → chapters 1–9 → concluding-words → about-the-author
|
|
||||||
*/
|
|
||||||
export async function getPqpaSidebarItems(): Promise<PqpaSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getPqpaMeta(),
|
|
||||||
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: PqpaSidebarItem[] = [];
|
|
||||||
|
|
||||||
// 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–9 (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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,280 +1,57 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Rāja-vidyā: The King of Knowledge (RV).
|
* Data access layer for Rāja-vidyā: The King of Knowledge (RV).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* RV is a simple prose book with 1 intro segment (front-pages) and 8 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "rv";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "rv";
|
// Re-export types with book-specific names for backwards compatibility
|
||||||
|
export type RvMeta = BookMeta;
|
||||||
|
export type RvChapterEntry = { number: number; title: string };
|
||||||
|
export type RvIntroSegment = IntroSegment;
|
||||||
|
export type RvChapter = Chapter;
|
||||||
|
export type RvSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type RvMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: RvChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RvChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RvIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RvChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type RvSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "2", … "8" */
|
|
||||||
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 getRvMeta(): Promise<RvMeta | null> {
|
export async function getRvMeta(): Promise<RvMeta | null> {
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
return getBookMeta(CODE);
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as RvMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getRvIntroSegments(): Promise<RvIntroSegment[]> {
|
export async function getRvIntroSegments(): Promise<RvIntroSegment[]> {
|
||||||
const results = await prisma.proseChapter.findMany({
|
return getIntroSegments(CODE);
|
||||||
where: { book: BOOK, volume: NO_VOLUME, type: "intro-segment" },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single intro segment by slug.
|
|
||||||
* Accepts both "intro-front-pages" (URL slug) and "front-pages" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getRvIntroSegment(
|
export async function getRvIntroSegment(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<RvIntroSegment | null> {
|
): Promise<RvIntroSegment | null> {
|
||||||
// DB stores with "intro-" prefix; accept either form
|
return getIntroSegment(CODE, slug);
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–8).
|
|
||||||
*/
|
|
||||||
export async function getRvChapter(
|
export async function getRvChapter(
|
||||||
chapterNum: number,
|
chapterNum: number,
|
||||||
): Promise<RvChapter | null> {
|
): Promise<RvChapter | null> {
|
||||||
if (chapterNum < 1 || chapterNum > 8 || !Number.isInteger(chapterNum)) {
|
return getChapter(CODE, String(chapterNum));
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug: String(chapterNum),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
book: doc.book,
|
|
||||||
chapter: doc.chapter,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
slug: doc.slug,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "8" → chapter
|
|
||||||
*/
|
|
||||||
export async function getRvContent(
|
export async function getRvContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<RvChapter | RvIntroSegment | null> {
|
): Promise<RvChapter | RvIntroSegment | null> {
|
||||||
if (slug.startsWith("intro-")) {
|
return getContent(CODE, slug);
|
||||||
return getRvIntroSegment(slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 8) {
|
|
||||||
return getRvChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–8
|
|
||||||
*/
|
|
||||||
export async function getRvSidebarItems(): Promise<RvSidebarItem[]> {
|
export async function getRvSidebarItems(): Promise<RvSidebarItem[]> {
|
||||||
const [meta, allDocs] = await Promise.all([
|
return getSidebarItems(CODE);
|
||||||
getRvMeta(),
|
|
||||||
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: RvSidebarItem[] = [];
|
|
||||||
|
|
||||||
// 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–8 (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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,52 @@ export function getSourceReference(): {
|
|||||||
return { title: parentTitle, detail: chapterTitle, url };
|
return { title: parentTitle, detail: chapterTitle, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /transcriptions/[slug]
|
||||||
|
if (
|
||||||
|
parts[0] === "transcriptions" &&
|
||||||
|
parts.length === 2 &&
|
||||||
|
parts[1] !== "year" &&
|
||||||
|
parts[1] !== "place" &&
|
||||||
|
parts[1] !== "type"
|
||||||
|
) {
|
||||||
|
const h1 = document.querySelector("h1");
|
||||||
|
const title =
|
||||||
|
"Transcripts: " + (h1?.textContent?.trim() || "Transcription");
|
||||||
|
|
||||||
|
// The metadata line contains badges + date + place + reference + duration
|
||||||
|
// Grab the text from the header's muted-foreground elements after h1
|
||||||
|
const metaContainer = document.querySelector(
|
||||||
|
"header .flex.items-center.gap-3",
|
||||||
|
);
|
||||||
|
if (metaContainer) {
|
||||||
|
// Collect text from spans (skip badges which are in their own elements)
|
||||||
|
const spans = metaContainer.querySelectorAll("span");
|
||||||
|
const metaParts: string[] = [];
|
||||||
|
spans.forEach((span) => {
|
||||||
|
const text = span.textContent?.trim();
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
text !== "Lecture" &&
|
||||||
|
text !== "Conversation" &&
|
||||||
|
text.length > 1 &&
|
||||||
|
!text.includes("min") &&
|
||||||
|
!span.closest(".text-primary")
|
||||||
|
) {
|
||||||
|
metaParts.push(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const detail = metaParts.join(" · ") || null;
|
||||||
|
return { title, detail, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try the <p> after h1
|
||||||
|
const metaP = document.querySelector(
|
||||||
|
"header p.text-muted-foreground, header p[class*='muted']",
|
||||||
|
);
|
||||||
|
const meta = metaP?.textContent?.trim() || null;
|
||||||
|
return { title, detail: meta, url };
|
||||||
|
}
|
||||||
|
|
||||||
// /glossary/[letter]?term=...
|
// /glossary/[letter]?term=...
|
||||||
if (parts[0] === "glossary") {
|
if (parts[0] === "glossary") {
|
||||||
const h1 = document.querySelector("h1");
|
const h1 = document.querySelector("h1");
|
||||||
|
|||||||
+33
-322
@@ -1,336 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for The Science of Self-Realization (SSR).
|
* Data access layer for The Science of Self-Realization (SSR).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* SSR is a large prose book with 6 intro segments (front-pages, dedication,
|
|
||||||
* foreword, introduction, appreciations, about-the-author) and 8 parent
|
|
||||||
* chapters, each containing multiple sub-chapters (e.g., "1-1", "1-2", "2-1").
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
getNavigableItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "ssr";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "ssr";
|
export type SsrMeta = BookMeta;
|
||||||
|
export type SsrSubChapterEntry = { slug: string; title: string };
|
||||||
|
export type SsrIntroSegment = IntroSegment;
|
||||||
|
export type SsrChapter = Chapter;
|
||||||
|
export type SsrSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getSsrMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type SsrMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: SsrParentChapter[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SsrParentChapter = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
subChapters: SsrSubChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SsrSubChapterEntry = {
|
|
||||||
slug: string; // "1-1", "1-2", etc.
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SsrIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SsrChapter = {
|
|
||||||
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 SsrSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1-1", "parent-1", "intro-about-the-author" */
|
|
||||||
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 getSsrIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getSsrIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getSsrChapter(slug: string) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, slug);
|
||||||
}
|
}
|
||||||
|
export async function getSsrContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
|
|
||||||
*/
|
|
||||||
export async function getSsrMeta(): Promise<SsrMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as SsrMeta;
|
|
||||||
}
|
}
|
||||||
|
export async function getSsrSidebarItems() {
|
||||||
// ---------------------------------------------------------------------------
|
return getSidebarItems(CODE);
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getSsrIntroSegments(): Promise<SsrIntroSegment[]> {
|
|
||||||
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 getSsrIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<SsrIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters (sub-chapters like "1-1", "2-3", etc.)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sub-chapter by its slug (e.g. "1-1", "2-3").
|
|
||||||
*/
|
|
||||||
export async function getSsrChapter(slug: string): Promise<SsrChapter | 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 getSsrMeta();
|
|
||||||
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-dedication" → intro segment
|
|
||||||
* "intro-about-the-author" → intro segment
|
|
||||||
* "1-1", "2-3", etc. → sub-chapter
|
|
||||||
*/
|
|
||||||
export async function getSsrContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<SsrChapter | SsrIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getSsrIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-chapter slug like "1-1", "2-3"
|
|
||||||
return getSsrChapter(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list with hierarchical structure.
|
|
||||||
*
|
|
||||||
* Order: front-pages → dedication → foreword → introduction → appreciations
|
|
||||||
* → parent-1 (label) → 1-1, 1-2, ... → parent-2 (label) → 2-1, 2-2, ...
|
|
||||||
* → about-the-author
|
|
||||||
*
|
|
||||||
* "parent" type items are non-clickable headings. Only "intro-segment" and
|
|
||||||
* "sub-chapter" items are navigable pages.
|
|
||||||
*/
|
|
||||||
export async function getSsrSidebarItems(): Promise<SsrSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getSsrMeta(),
|
|
||||||
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: SsrSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments that come BEFORE chapters (order < 99)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && doc.order < 99) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 >= 99) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
parentChapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the flat list of navigable sidebar items (excludes "parent" headings).
|
|
||||||
* Used for prev/next navigation.
|
|
||||||
*/
|
|
||||||
export function getNavigableItems(items: SsrSidebarItem[]): SsrSidebarItem[] {
|
|
||||||
return items.filter((item) => item.type !== "parent");
|
|
||||||
}
|
}
|
||||||
|
export { getNavigableItems };
|
||||||
|
|||||||
+34
-487
@@ -1,501 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Songs of the Vaiṣṇava Ācāryas (SVA).
|
* Data access layer for Songs of the Vaiṣṇava Ācāryas (SVA).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* SVA is a hierarchical song book with 4 parent parts, each containing
|
* SVA is a hierarchical book with parent chapters and sub-chapters (songs).
|
||||||
* multiple songs (sub-chapters). Each song is stored as its own
|
|
||||||
* prose_chapters document with slugs like "1-1", "1-2", "2-1", etc.
|
|
||||||
*
|
|
||||||
* When the DB isn't seeded yet, falls back to reading local JSON files
|
|
||||||
* from lib/jsons/books/sva/.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
import fs from "fs";
|
getIntroSegments,
|
||||||
import path from "path";
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
getNavigableItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "sva";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "sva";
|
export type SvaMeta = BookMeta;
|
||||||
|
export type SvaSubChapterEntry = { slug: string; title: string };
|
||||||
|
export type SvaIntroSegment = IntroSegment;
|
||||||
|
export type SvaChapter = Chapter;
|
||||||
|
export type SvaSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getSvaMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
const SVA_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "sva");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type SvaMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: SvaParentChapter[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SvaParentChapter = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
subChapters: SvaSubChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SvaSubChapterEntry = {
|
|
||||||
slug: string; // "1-1", "1-2", etc.
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SvaIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SvaChapter = {
|
|
||||||
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 SvaSidebarItem = {
|
|
||||||
/** 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 getSvaIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getSvaIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getSvaChapter(slug: string) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, slug);
|
||||||
}
|
}
|
||||||
|
export async function getSvaContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
export async function getSvaSidebarItems() {
|
||||||
|
return getSidebarItems(CODE);
|
||||||
}
|
}
|
||||||
|
export { getNavigableItems };
|
||||||
function getLocalIntroSegments(): SvaIntroSegment[] {
|
|
||||||
const introDir = path.join(SVA_JSON_DIR, "intro");
|
|
||||||
if (!fs.existsSync(introDir)) return [];
|
|
||||||
|
|
||||||
const results: SvaIntroSegment[] = [];
|
|
||||||
const files = ["front-pages.json", "foreword.json", "introduction.json"];
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(introDir, file);
|
|
||||||
const seg = readLocalJson<SvaIntroSegment>(filePath);
|
|
||||||
if (seg) {
|
|
||||||
results.push({
|
|
||||||
...seg,
|
|
||||||
slug: seg.slug.startsWith("intro-") ? seg.slug : `intro-${seg.slug}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results.sort((a, b) => a.order - b.order);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalSubChapter(slug: string): SvaChapter | null {
|
|
||||||
const filePath = path.join(SVA_JSON_DIR, "chapters", `${slug}.json`);
|
|
||||||
return readLocalJson<SvaChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list with sub-chapters, etc.)
|
|
||||||
*/
|
|
||||||
export async function getSvaMeta(): Promise<SvaMeta | null> {
|
|
||||||
try {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as SvaMeta;
|
|
||||||
} catch {
|
|
||||||
// fall through to local JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(SVA_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<SvaMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getSvaIntroSegments(): Promise<SvaIntroSegment[]> {
|
|
||||||
try {
|
|
||||||
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),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to local JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 getSvaIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<SvaIntroSegment | 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;
|
|
||||||
|
|
||||||
try {
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to local JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const filePath = path.join(SVA_JSON_DIR, "intro", `${rawSlug}.json`);
|
|
||||||
const local = readLocalJson<SvaIntroSegment>(filePath);
|
|
||||||
if (!local) return null;
|
|
||||||
return {
|
|
||||||
...local,
|
|
||||||
slug: dbSlug,
|
|
||||||
paragraphs: local.paragraphs.map(sanitizeHtml),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters (sub-chapters / songs like "1-1", "2-3", etc.)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sub-chapter (song) by its slug (e.g. "1-1", "2-3").
|
|
||||||
*/
|
|
||||||
export async function getSvaChapter(slug: string): Promise<SvaChapter | null> {
|
|
||||||
try {
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: {
|
|
||||||
book: BOOK,
|
|
||||||
volume: NO_VOLUME,
|
|
||||||
slug,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
// 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 getSvaMeta();
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to local JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const local = getLocalSubChapter(slug);
|
|
||||||
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-foreword" → intro segment
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "1-1", "2-3", etc. → sub-chapter (song)
|
|
||||||
*/
|
|
||||||
export async function getSvaContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<SvaChapter | SvaIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getSvaIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-chapter slug like "1-1", "2-3"
|
|
||||||
return getSvaChapter(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list with hierarchical structure.
|
|
||||||
*
|
|
||||||
* Order: intro segments (front-pages, foreword, introduction)
|
|
||||||
* → parent-1 (label) → 1-1, 1-2, ...
|
|
||||||
* → parent-2 (label) → 2-1, 2-2, ...
|
|
||||||
* → etc.
|
|
||||||
*
|
|
||||||
* "parent" type items are non-clickable headings. Only "intro-segment" and
|
|
||||||
* "sub-chapter" items are navigable pages.
|
|
||||||
*/
|
|
||||||
export async function getSvaSidebarItems(): Promise<SvaSidebarItem[]> {
|
|
||||||
let allDocs: {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
chapter: number | null;
|
|
||||||
order: number;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
const meta = await getSvaMeta();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await prisma.proseChapter.findMany({
|
|
||||||
where: { book: BOOK, volume: NO_VOLUME },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
title: true,
|
|
||||||
type: true,
|
|
||||||
chapter: true,
|
|
||||||
order: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
allDocs = results;
|
|
||||||
} catch {
|
|
||||||
// Prisma error — build sidebar from meta alone
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!meta) {
|
|
||||||
// Try building from local JSON
|
|
||||||
const localMeta = readLocalJson<SvaMeta>(
|
|
||||||
path.join(SVA_JSON_DIR, "intro.json"),
|
|
||||||
);
|
|
||||||
if (!localMeta) return [];
|
|
||||||
return buildSidebarFromMeta(localMeta);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have no DB docs, build from meta alone
|
|
||||||
if (allDocs.length === 0) {
|
|
||||||
return buildSidebarFromMeta(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: SvaSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments that come BEFORE chapters (order < 99)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && doc.order < 99) {
|
|
||||||
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: `Part ${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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intro segments that come AFTER chapters (order >= 99)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && doc.order >= 99) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
parentChapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build sidebar items purely from metadata (when DB is unavailable).
|
|
||||||
*/
|
|
||||||
function buildSidebarFromMeta(meta: SvaMeta): SvaSidebarItem[] {
|
|
||||||
const items: SvaSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Hardcoded intro segments
|
|
||||||
items.push({
|
|
||||||
slug: "intro-front-pages",
|
|
||||||
label: "Front Pages",
|
|
||||||
type: "intro-segment",
|
|
||||||
parentChapter: null,
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
slug: "intro-foreword",
|
|
||||||
label: "Foreword",
|
|
||||||
type: "intro-segment",
|
|
||||||
parentChapter: null,
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
slug: "intro-introduction",
|
|
||||||
label: "Introduction",
|
|
||||||
type: "intro-segment",
|
|
||||||
parentChapter: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const ch of meta.chapters) {
|
|
||||||
items.push({
|
|
||||||
slug: `parent-${ch.number}`,
|
|
||||||
label: `Part ${ch.number}: ${ch.title}`,
|
|
||||||
type: "parent",
|
|
||||||
parentChapter: ch.number,
|
|
||||||
});
|
|
||||||
|
|
||||||
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: SvaSidebarItem[]): SvaSidebarItem[] {
|
|
||||||
return items.filter((item) => item.type !== "parent");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,164 +1,52 @@
|
|||||||
/**
|
import {
|
||||||
* Data access layer for Two Essays (TE).
|
getBookMeta,
|
||||||
*
|
getIntroSegments,
|
||||||
* Reads from MongoDB via Prisma.
|
getIntroSegment,
|
||||||
* TE is a collection of 2 standalone essays using the `essays` collection
|
getChapter,
|
||||||
* (not `prose_chapters`). Essays are grouped by `group: "two-essays"`.
|
getContent,
|
||||||
*
|
getSidebarItems,
|
||||||
* Collections used:
|
type BookMeta,
|
||||||
* - book_meta (via Prisma BookMeta model)
|
type IntroSegment,
|
||||||
* - essays (via Prisma Essay model)
|
type Chapter,
|
||||||
*/
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
const CODE = "te";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export type TeMeta = BookMeta;
|
||||||
// Constants
|
export type TeEssayEntry = { slug: string; title: string; order: number };
|
||||||
// ---------------------------------------------------------------------------
|
export type TeIntroSegment = IntroSegment;
|
||||||
|
export type TeEssay = Chapter;
|
||||||
|
export type TeChapter = Chapter;
|
||||||
|
export type TeSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const GROUP = "two-essays";
|
export async function getTeMeta() {
|
||||||
const BOOK = "te";
|
return getBookMeta(CODE);
|
||||||
|
}
|
||||||
// ---------------------------------------------------------------------------
|
export async function getTeIntroSegments() {
|
||||||
// Types
|
return getIntroSegments(CODE);
|
||||||
// ---------------------------------------------------------------------------
|
}
|
||||||
|
export async function getTeIntroSegment(slug: string) {
|
||||||
export type TeMeta = {
|
return getIntroSegment(CODE, slug);
|
||||||
group: string;
|
}
|
||||||
title: string;
|
export async function getTeChapter(slug: string) {
|
||||||
author: string;
|
return getChapter(CODE, slug);
|
||||||
year: number;
|
}
|
||||||
description: string;
|
export async function getTeContent(slug: string) {
|
||||||
essays: TeEssayEntry[];
|
return getContent(CODE, slug);
|
||||||
};
|
}
|
||||||
|
export async function getTeSidebarItems() {
|
||||||
export type TeEssayEntry = {
|
return getSidebarItems(CODE);
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
order: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TeEssay = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number | null;
|
|
||||||
group: string | null;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TeSidebarItem = {
|
|
||||||
slug: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTML sanitisation (mirrors lib/ej.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, essay list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getTeMeta(): Promise<TeMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as TeMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Essays
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All essays in the "two-essays" group, ordered by `order`.
|
|
||||||
*/
|
|
||||||
export async function getTeEssays(): Promise<TeEssay[]> {
|
export async function getTeEssays(): Promise<TeEssay[]> {
|
||||||
const results = await prisma.essay.findMany({
|
const items = await getIntroSegments(CODE);
|
||||||
where: { group: GROUP },
|
return items as unknown as TeEssay[];
|
||||||
orderBy: { order: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.map((doc) => ({
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
author: doc.author,
|
|
||||||
year: doc.year,
|
|
||||||
group: doc.group,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single essay by slug.
|
|
||||||
*/
|
|
||||||
export async function getTeEssay(slug: string): Promise<TeEssay | null> {
|
export async function getTeEssay(slug: string): Promise<TeEssay | null> {
|
||||||
const doc = await prisma.essay.findUnique({ where: { slug } });
|
const chapter = await getChapter(CODE, slug);
|
||||||
if (!doc) return null;
|
if (chapter) return chapter;
|
||||||
|
const intro = await getIntroSegment(CODE, slug);
|
||||||
return {
|
return intro as unknown as TeEssay | null;
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
author: doc.author,
|
|
||||||
year: doc.year,
|
|
||||||
group: doc.group,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the sidebar navigation list from meta.
|
|
||||||
*/
|
|
||||||
export async function getTeSidebarItems(): Promise<TeSidebarItem[]> {
|
|
||||||
const meta = await getTeMeta();
|
|
||||||
if (!meta) return [];
|
|
||||||
return meta.essays.map((e) => ({
|
|
||||||
slug: e.slug,
|
|
||||||
label: e.title,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-278
@@ -1,292 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Teachings of Lord Caitanya (TLC).
|
* Data access layer for Teachings of Lord Caitanya (TLC).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* TLC is a prose book (like KB) but has no volume structure —
|
|
||||||
* we use volume=0 as the sentinel for "no volumes".
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "tlc";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export type TlcMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type TlcChapterEntry = { number: number; title: string };
|
||||||
|
export type TlcIntroSegment = IntroSegment;
|
||||||
|
export type TlcChapter = Chapter;
|
||||||
|
export type TlcSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "tlc";
|
export async function getTlcMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TlcMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
parts: TlcPart[];
|
|
||||||
chapters: TlcChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlcPart = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
chapters: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlcChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlcIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlcChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type TlcSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-flap", "1", "32" */
|
|
||||||
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 getTlcIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getTlcIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getTlcChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getTlcContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, parts, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getTlcMeta(): Promise<TlcMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as TlcMeta;
|
|
||||||
}
|
}
|
||||||
|
export async function getTlcSidebarItems() {
|
||||||
// ---------------------------------------------------------------------------
|
return getSidebarItems(CODE);
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getTlcIntroSegments(): Promise<TlcIntroSegment[]> {
|
|
||||||
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-preface" (URL slug) and "preface" (raw slug).
|
|
||||||
*/
|
|
||||||
export async function getTlcIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<TlcIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–32).
|
|
||||||
*/
|
|
||||||
export async function getTlcChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<TlcChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 32 || !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-flap" → intro segment
|
|
||||||
* "intro-preface" → intro segment
|
|
||||||
* "intro-introduction" → intro segment
|
|
||||||
* "1" … "32" → chapter
|
|
||||||
*/
|
|
||||||
export async function getTlcContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<TlcChapter | TlcIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getTlcIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 32) {
|
|
||||||
return getTlcChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: intro segments → chapters 1–32
|
|
||||||
* (TLC has no concluding-words section.)
|
|
||||||
*/
|
|
||||||
export async function getTlcSidebarItems(): Promise<TlcSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getTlcMeta(),
|
|
||||||
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: TlcSidebarItem[] = [];
|
|
||||||
|
|
||||||
// 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–32 (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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-439
@@ -1,453 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Teachings of Lord Kapila (TLK).
|
* Data access layer for Teachings of Lord Kapila (TLK).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* TLK 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/tlk/.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
import fs from "fs";
|
getIntroSegments,
|
||||||
import path from "path";
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "tlk";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export type TlkMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type TlkChapterEntry = { number: number; title: string };
|
||||||
|
export type TlkIntroSegment = IntroSegment;
|
||||||
|
export type TlkChapter = Chapter;
|
||||||
|
export type TlkSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "tlk";
|
export async function getTlkMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
const TLK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "tlk");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TlkMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: TlkChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlkChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlkIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlkChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type TlkSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "18", "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 getTlkIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getTlkIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getTlkChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getTlkContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
export async function getTlkSidebarItems() {
|
||||||
|
return getSidebarItems(CODE);
|
||||||
function getLocalIntroSegments(): TlkIntroSegment[] {
|
|
||||||
const introDir = path.join(TLK_JSON_DIR, "intro");
|
|
||||||
if (!fs.existsSync(introDir)) return [];
|
|
||||||
|
|
||||||
const results: TlkIntroSegment[] = [];
|
|
||||||
const files = [
|
|
||||||
"front-pages.json",
|
|
||||||
"foreword.json",
|
|
||||||
"about-the-author.json",
|
|
||||||
"credits.json",
|
|
||||||
"back-flap.json",
|
|
||||||
];
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(introDir, file);
|
|
||||||
const seg = readLocalJson<TlkIntroSegment>(filePath);
|
|
||||||
if (seg) results.push(seg);
|
|
||||||
}
|
|
||||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalIntroSegment(rawSlug: string): TlkIntroSegment | null {
|
|
||||||
const filePath = path.join(TLK_JSON_DIR, "intro", `${rawSlug}.json`);
|
|
||||||
return readLocalJson<TlkIntroSegment>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): TlkChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
TLK_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<TlkChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getTlkMeta(): Promise<TlkMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as TlkMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(TLK_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<TlkMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getTlkIntroSegments(): Promise<TlkIntroSegment[]> {
|
|
||||||
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 getTlkIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<TlkIntroSegment | 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 getTlkChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<TlkChapter | 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-front-pages" → intro segment
|
|
||||||
* "intro-foreword" → intro segment
|
|
||||||
* "intro-about-the-author" → intro segment
|
|
||||||
* "intro-credits" → intro segment
|
|
||||||
* "intro-back-flap" → intro segment
|
|
||||||
* "1" … "18" → chapter
|
|
||||||
*/
|
|
||||||
export async function getTlkContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<TlkChapter | TlkIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getTlkIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 18) {
|
|
||||||
return getTlkChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → foreword → chapters 1–18 → about-the-author → credits → back-flap
|
|
||||||
*/
|
|
||||||
export async function getTlkSidebarItems(): Promise<TlkSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getTlkMeta(),
|
|
||||||
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: TlkMeta,
|
|
||||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
|
||||||
): TlkSidebarItem[] {
|
|
||||||
const items: TlkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments that come BEFORE chapters (order < 50)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 50) {
|
|
||||||
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 >= 50, e.g. about-the-author, credits, back-flap)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 50) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: TlkMeta | null): TlkSidebarItem[] {
|
|
||||||
const items: TlkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Local intro segments (front matter)
|
|
||||||
const introSegs = getLocalIntroSegments();
|
|
||||||
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 50);
|
|
||||||
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 50);
|
|
||||||
|
|
||||||
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(TLK_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<TlkChapter>(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, credits, back-flap)
|
|
||||||
for (const seg of backMatter) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-435
@@ -1,449 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Teachings of Queen Kuntī (TQK).
|
* Data access layer for Teachings of Queen Kuntī (TQK).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma, same as the rest of the app.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* TQK 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/tqk/.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
import fs from "fs";
|
getIntroSegments,
|
||||||
import path from "path";
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "tqk";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export type TqkMeta = BookMeta;
|
||||||
const NO_VOLUME = 0;
|
export type TqkChapterEntry = { number: number; title: string };
|
||||||
|
export type TqkIntroSegment = IntroSegment;
|
||||||
|
export type TqkChapter = Chapter;
|
||||||
|
export type TqkSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "tqk";
|
export async function getTqkMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
const TQK_JSON_DIR = path.join(process.cwd(), "lib", "jsons", "books", "tqk");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TqkMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year?: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: TqkChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TqkChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TqkIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TqkChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type TqkSidebarItem = {
|
|
||||||
/** URL-safe slug: "intro-front-pages", "1", "26", "intro-back-flap" */
|
|
||||||
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 getTqkIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getTqkIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getTqkChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getTqkContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Local JSON helpers (fallback when DB is not seeded)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readLocalJson<T>(filePath: string): T | null {
|
|
||||||
if (!fs.existsSync(filePath)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
export async function getTqkSidebarItems() {
|
||||||
|
return getSidebarItems(CODE);
|
||||||
function getLocalIntroSegments(): TqkIntroSegment[] {
|
|
||||||
const introDir = path.join(TQK_JSON_DIR, "intro");
|
|
||||||
if (!fs.existsSync(introDir)) return [];
|
|
||||||
|
|
||||||
const results: TqkIntroSegment[] = [];
|
|
||||||
const files = [
|
|
||||||
"front-pages.json",
|
|
||||||
"introduction.json",
|
|
||||||
"back-flap.json",
|
|
||||||
];
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(introDir, file);
|
|
||||||
const seg = readLocalJson<TqkIntroSegment>(filePath);
|
|
||||||
if (seg) results.push(seg);
|
|
||||||
}
|
|
||||||
return results.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalIntroSegment(rawSlug: string): TqkIntroSegment | null {
|
|
||||||
const filePath = path.join(TQK_JSON_DIR, "intro", `${rawSlug}.json`);
|
|
||||||
return readLocalJson<TqkIntroSegment>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalChapter(chapterNum: number): TqkChapter | null {
|
|
||||||
const filePath = path.join(
|
|
||||||
TQK_JSON_DIR,
|
|
||||||
"chapters",
|
|
||||||
String(chapterNum).padStart(2, "0") + ".json",
|
|
||||||
);
|
|
||||||
return readLocalJson<TqkChapter>(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getTqkMeta(): Promise<TqkMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (meta) return meta.data as unknown as TqkMeta;
|
|
||||||
|
|
||||||
// Fallback to local JSON
|
|
||||||
const introPath = path.join(TQK_JSON_DIR, "intro.json");
|
|
||||||
return readLocalJson<TqkMeta>(introPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getTqkIntroSegments(): Promise<TqkIntroSegment[]> {
|
|
||||||
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 getTqkIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<TqkIntroSegment | 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–26).
|
|
||||||
*/
|
|
||||||
export async function getTqkChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<TqkChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 26 || !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-introduction" → intro segment
|
|
||||||
* "intro-back-flap" → intro segment
|
|
||||||
* "1" … "26" → chapter
|
|
||||||
*/
|
|
||||||
export async function getTqkContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<TqkChapter | TqkIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getTqkIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 26) {
|
|
||||||
return getTqkChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → introduction → chapters 1–26 → back-flap
|
|
||||||
*/
|
|
||||||
export async function getTqkSidebarItems(): Promise<TqkSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getTqkMeta(),
|
|
||||||
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: TqkMeta,
|
|
||||||
allDocs: { slug: string; title: string; type: string; chapter: number | null; order: number | null }[],
|
|
||||||
): TqkSidebarItem[] {
|
|
||||||
const items: TqkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Intro segments that come BEFORE chapters (order < 50)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) < 50) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapters 1–26 (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 >= 50, e.g. back-flap)
|
|
||||||
for (const doc of allDocs) {
|
|
||||||
if (doc.type === "intro-segment" && (doc.order ?? 0) >= 50) {
|
|
||||||
items.push({
|
|
||||||
slug: doc.slug,
|
|
||||||
label: doc.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebarFromLocal(meta: TqkMeta | null): TqkSidebarItem[] {
|
|
||||||
const items: TqkSidebarItem[] = [];
|
|
||||||
|
|
||||||
// Local intro segments (front matter)
|
|
||||||
const introSegs = getLocalIntroSegments();
|
|
||||||
const frontMatter = introSegs.filter((s) => (s.order ?? 0) < 50);
|
|
||||||
const backMatter = introSegs.filter((s) => (s.order ?? 0) >= 50);
|
|
||||||
|
|
||||||
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-26
|
|
||||||
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(TQK_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<TqkChapter>(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 (back-flap)
|
|
||||||
for (const seg of backMatter) {
|
|
||||||
items.push({
|
|
||||||
slug: `intro-${seg.slug}`,
|
|
||||||
label: seg.title,
|
|
||||||
type: "intro-segment",
|
|
||||||
chapter: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-134
@@ -1,16 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Transcriptions.
|
* Data access layer for Transcriptions.
|
||||||
*
|
*
|
||||||
* Uses Prisma to query the `transcriptions` collection in MongoDB.
|
* Calls the Go BFF API instead of Prisma directly.
|
||||||
* Replaces the previous local-JSON-file approach so that data is always
|
* All responses are unwrapped from the BFF envelope by lib/bff.ts.
|
||||||
* served from the database (seeded via seed-transcriptions.ts).
|
|
||||||
*
|
*
|
||||||
* Some documents store their content as a JSON string in `contentJson`
|
* BFF endpoints used:
|
||||||
* (a BSON wire-protocol workaround for very large nested arrays).
|
* GET /api/v1/transcriptions → { count }
|
||||||
* The `getTranscription()` helper transparently parses that fallback.
|
* GET /api/v1/transcriptions/years → [{ _id, count }]
|
||||||
|
* GET /api/v1/transcriptions/places → [{ _id, count }]
|
||||||
|
* GET /api/v1/transcriptions/types → { lectures, conversations }
|
||||||
|
* GET /api/v1/transcriptions/year/{year} → [TranscriptionSummary]
|
||||||
|
* GET /api/v1/transcriptions/place/{place} → [TranscriptionSummary]
|
||||||
|
* GET /api/v1/transcriptions/type/{type} → [TranscriptionSummary]
|
||||||
|
* GET /api/v1/transcriptions/{slug} → TranscriptionFull
|
||||||
|
* GET /api/v1/transcriptions/adjacent/{slug}?year=N → { prev, next }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { bff, bffList } from "@/lib/bff";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -54,25 +60,37 @@ export type PlaceGroup = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prisma select objects (keep queries lightweight)
|
// BFF response types (snake_case from Go)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Fields needed for summary views (no content). */
|
type BffSummary = {
|
||||||
const SUMMARY_SELECT = {
|
id?: string;
|
||||||
slug: true,
|
slug: string;
|
||||||
name: true,
|
name: string;
|
||||||
type: true,
|
type: string;
|
||||||
date: true,
|
date?: string | null;
|
||||||
year: true,
|
year?: number | null;
|
||||||
month: true,
|
month?: number | null;
|
||||||
day: true,
|
day?: number | null;
|
||||||
place: true,
|
place?: string | null;
|
||||||
context: true,
|
context?: string | null;
|
||||||
reference: true,
|
reference?: string | null;
|
||||||
durationMinutes: true,
|
duration_minutes?: number | null;
|
||||||
participants: true,
|
audio_url?: string | null;
|
||||||
audioUrl: true,
|
participants?: string[];
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
|
type BffFull = BffSummary & {
|
||||||
|
content?: BffContentBlock[];
|
||||||
|
content_json?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BffContentBlock = {
|
||||||
|
text: string;
|
||||||
|
format: string;
|
||||||
|
alignment: string;
|
||||||
|
speaker?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sanitize
|
// Sanitize
|
||||||
@@ -90,49 +108,88 @@ function sanitizeHtml(html: string): string {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mappers (snake_case BFF → camelCase frontend)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function mapSummary(s: BffSummary): TranscriptionSummary {
|
||||||
|
return {
|
||||||
|
slug: s.slug,
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
date: s.date ?? null,
|
||||||
|
year: s.year ?? null,
|
||||||
|
month: s.month ?? null,
|
||||||
|
day: s.day ?? null,
|
||||||
|
place: s.place ?? null,
|
||||||
|
context: s.context ?? null,
|
||||||
|
reference: s.reference ?? null,
|
||||||
|
durationMinutes: s.duration_minutes ?? null,
|
||||||
|
participants: s.participants ?? [],
|
||||||
|
audioUrl: s.audio_url ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapContentBlock(b: BffContentBlock): ContentBlock {
|
||||||
|
return {
|
||||||
|
text: sanitizeHtml(b.text),
|
||||||
|
format: b.format,
|
||||||
|
alignment: b.alignment,
|
||||||
|
speaker: b.speaker ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFull(t: BffFull): TranscriptionFull {
|
||||||
|
let content: ContentBlock[] = [];
|
||||||
|
|
||||||
|
if (t.content && t.content.length > 0) {
|
||||||
|
content = t.content.map(mapContentBlock);
|
||||||
|
} else if (t.content_json && t.content_json.length > 2) {
|
||||||
|
// contentJson fallback (BSON workaround for very large nested arrays)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(t.content_json) as BffContentBlock[];
|
||||||
|
content = parsed.map(mapContentBlock);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors — return empty content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapSummary(t),
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Index / overview queries
|
// Index / overview queries
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getTranscriptionYears(): Promise<YearGroup[]> {
|
export async function getTranscriptionYears(): Promise<YearGroup[]> {
|
||||||
const transcriptions = await prisma.transcription.findMany({
|
const raw = await bffList<{ _id: number; count: number }>(
|
||||||
where: { year: { not: null }, isPublished: true },
|
"/transcriptions/years",
|
||||||
select: { year: true },
|
{ tags: ["transcriptions"] },
|
||||||
});
|
);
|
||||||
|
return raw
|
||||||
const counts: Record<number, number> = {};
|
.map((item) => ({ year: item._id, count: item.count }))
|
||||||
for (const t of transcriptions) {
|
|
||||||
if (t.year) {
|
|
||||||
counts[t.year] = (counts[t.year] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(counts)
|
|
||||||
.map(([year, count]) => ({ year: Number(year), count }))
|
|
||||||
.filter((g) => g.year >= 1960 && g.year <= 1980)
|
.filter((g) => g.year >= 1960 && g.year <= 1980)
|
||||||
.sort((a, b) => a.year - b.year);
|
.sort((a, b) => a.year - b.year);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTranscriptionPlaces(): Promise<PlaceGroup[]> {
|
export async function getTranscriptionPlaces(): Promise<PlaceGroup[]> {
|
||||||
const transcriptions = await prisma.transcription.findMany({
|
const raw = await bffList<{ _id: string; count: number }>(
|
||||||
where: { place: { not: null }, isPublished: true },
|
"/transcriptions/places",
|
||||||
select: { place: true },
|
{ tags: ["transcriptions"] },
|
||||||
});
|
);
|
||||||
|
return raw
|
||||||
const counts: Record<string, number> = {};
|
.map((item) => ({ name: item._id, count: item.count }))
|
||||||
for (const t of transcriptions) {
|
|
||||||
if (t.place) {
|
|
||||||
counts[t.place] = (counts[t.place] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(counts)
|
|
||||||
.map(([name, count]) => ({ name, count }))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTotalTranscriptionCount(): Promise<number> {
|
export async function getTotalTranscriptionCount(): Promise<number> {
|
||||||
return prisma.transcription.count({ where: { isPublished: true } });
|
const res = await bff<{ count: number }>("/transcriptions", {
|
||||||
|
tags: ["transcriptions"],
|
||||||
|
});
|
||||||
|
return res?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,21 +200,18 @@ export async function getTranscriptionIndex(): Promise<{
|
|||||||
totalTranscriptions: number;
|
totalTranscriptions: number;
|
||||||
typeDistribution: Record<string, number>;
|
typeDistribution: Record<string, number>;
|
||||||
}> {
|
}> {
|
||||||
const [total, lectures, conversations] = await Promise.all([
|
const [countRes, typesRes] = await Promise.all([
|
||||||
prisma.transcription.count({ where: { isPublished: true } }),
|
bff<{ count: number }>("/transcriptions", { tags: ["transcriptions"] }),
|
||||||
prisma.transcription.count({
|
bff<{ lectures: number; conversations: number }>("/transcriptions/types", {
|
||||||
where: { isPublished: true, type: "lecture" },
|
tags: ["transcriptions"],
|
||||||
}),
|
|
||||||
prisma.transcription.count({
|
|
||||||
where: { isPublished: true, type: "conversation" },
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTranscriptions: total,
|
totalTranscriptions: countRes?.count ?? 0,
|
||||||
typeDistribution: {
|
typeDistribution: {
|
||||||
lecture: lectures,
|
lecture: typesRes?.lectures ?? 0,
|
||||||
conversation: conversations,
|
conversation: typesRes?.conversations ?? 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -169,34 +223,29 @@ export async function getTranscriptionIndex(): Promise<{
|
|||||||
export async function getTranscriptionsByYear(
|
export async function getTranscriptionsByYear(
|
||||||
year: number,
|
year: number,
|
||||||
): Promise<TranscriptionSummary[]> {
|
): Promise<TranscriptionSummary[]> {
|
||||||
const transcriptions = await prisma.transcription.findMany({
|
const raw = await bffList<BffSummary>(`/transcriptions/year/${year}`, {
|
||||||
where: { year, isPublished: true },
|
tags: ["transcriptions"],
|
||||||
orderBy: [{ month: "asc" }, { day: "asc" }],
|
|
||||||
select: SUMMARY_SELECT,
|
|
||||||
});
|
});
|
||||||
return transcriptions;
|
return raw.map(mapSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTranscriptionsByPlace(
|
export async function getTranscriptionsByPlace(
|
||||||
place: string,
|
place: string,
|
||||||
): Promise<TranscriptionSummary[]> {
|
): Promise<TranscriptionSummary[]> {
|
||||||
const transcriptions = await prisma.transcription.findMany({
|
const encoded = encodeURIComponent(place);
|
||||||
where: { place, isPublished: true },
|
const raw = await bffList<BffSummary>(`/transcriptions/place/${encoded}`, {
|
||||||
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
tags: ["transcriptions"],
|
||||||
select: SUMMARY_SELECT,
|
|
||||||
});
|
});
|
||||||
return transcriptions;
|
return raw.map(mapSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTranscriptionsByType(
|
export async function getTranscriptionsByType(
|
||||||
type: string,
|
type: string,
|
||||||
): Promise<TranscriptionSummary[]> {
|
): Promise<TranscriptionSummary[]> {
|
||||||
const transcriptions = await prisma.transcription.findMany({
|
const raw = await bffList<BffSummary>(`/transcriptions/type/${type}`, {
|
||||||
where: { type, isPublished: true },
|
tags: ["transcriptions"],
|
||||||
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
|
||||||
select: SUMMARY_SELECT,
|
|
||||||
});
|
});
|
||||||
return transcriptions;
|
return raw.map(mapSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -206,54 +255,11 @@ export async function getTranscriptionsByType(
|
|||||||
export async function getTranscription(
|
export async function getTranscription(
|
||||||
slug: string,
|
slug: string,
|
||||||
): Promise<TranscriptionFull | null> {
|
): Promise<TranscriptionFull | null> {
|
||||||
const t = await prisma.transcription.findUnique({ where: { slug } });
|
const raw = await bff<BffFull>(`/transcriptions/${slug}`, {
|
||||||
if (!t || !t.isPublished) return null;
|
tags: ["transcriptions"],
|
||||||
|
});
|
||||||
// Content may be stored as embedded array or as a JSON string fallback
|
if (!raw) return null;
|
||||||
let content: ContentBlock[] = [];
|
return mapFull(raw);
|
||||||
|
|
||||||
if (t.content && t.content.length > 0) {
|
|
||||||
content = t.content.map((block) => ({
|
|
||||||
text: sanitizeHtml(block.text),
|
|
||||||
format: block.format,
|
|
||||||
alignment: block.alignment,
|
|
||||||
speaker: block.speaker ?? null,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// Check for contentJson fallback (BSON workaround for large documents)
|
|
||||||
const raw = t as unknown as Record<string, unknown>;
|
|
||||||
const contentJson = raw.contentJson;
|
|
||||||
if (typeof contentJson === "string" && contentJson.length > 2) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(contentJson) as ContentBlock[];
|
|
||||||
content = parsed.map((block) => ({
|
|
||||||
text: sanitizeHtml(block.text),
|
|
||||||
format: block.format,
|
|
||||||
alignment: block.alignment,
|
|
||||||
speaker: block.speaker ?? null,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors — return empty content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: t.slug,
|
|
||||||
name: t.name,
|
|
||||||
type: t.type,
|
|
||||||
date: t.date,
|
|
||||||
year: t.year,
|
|
||||||
month: t.month,
|
|
||||||
day: t.day,
|
|
||||||
place: t.place,
|
|
||||||
context: t.context,
|
|
||||||
reference: t.reference,
|
|
||||||
durationMinutes: t.durationMinutes,
|
|
||||||
participants: t.participants,
|
|
||||||
audioUrl: t.audioUrl,
|
|
||||||
content,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -269,16 +275,18 @@ export async function getAdjacentTranscriptions(
|
|||||||
}> {
|
}> {
|
||||||
if (!year) return { prev: null, next: null };
|
if (!year) return { prev: null, next: null };
|
||||||
|
|
||||||
const yearTranscriptions = await prisma.transcription.findMany({
|
const res = await bff<{
|
||||||
where: { year, isPublished: true },
|
prev: BffSummary | null;
|
||||||
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
|
next: BffSummary | null;
|
||||||
select: SUMMARY_SELECT,
|
}>(`/transcriptions/adjacent/${slug}`, {
|
||||||
|
params: { year: String(year) },
|
||||||
|
tags: ["transcriptions"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const idx = yearTranscriptions.findIndex((t) => t.slug === slug);
|
if (!res) return { prev: null, next: null };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prev: idx > 0 ? yearTranscriptions[idx - 1] : null,
|
prev: res.prev ? mapSummary(res.prev) : null,
|
||||||
next:
|
next: res.next ? mapSummary(res.next) : null,
|
||||||
idx < yearTranscriptions.length - 1 ? yearTranscriptions[idx + 1] : null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-266
@@ -1,280 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for Transcendental Teachings of Prahlāda Mahārāja (TTP).
|
* Data access layer for Transcendental Teachings of Prahlāda Mahārāja (TTP).
|
||||||
*
|
*
|
||||||
* Reads from MongoDB via Prisma.
|
* Delegates to the generic book data layer which calls the BFF API.
|
||||||
* TTP is a prose book with 1 intro segment (front-pages) and 5 chapters.
|
|
||||||
*
|
|
||||||
* Collections used:
|
|
||||||
* - book_meta (via Prisma BookMeta model)
|
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import {
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
getBookMeta,
|
||||||
|
getIntroSegments,
|
||||||
|
getIntroSegment,
|
||||||
|
getChapter,
|
||||||
|
getContent,
|
||||||
|
getSidebarItems,
|
||||||
|
type BookMeta,
|
||||||
|
type IntroSegment,
|
||||||
|
type Chapter,
|
||||||
|
type SidebarItem,
|
||||||
|
} from "@/lib/books";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const CODE = "ttp";
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BOOK = "ttp";
|
export type TtpMeta = BookMeta;
|
||||||
|
export type TtpChapterEntry = { number: number; title: string };
|
||||||
|
export type TtpIntroSegment = IntroSegment;
|
||||||
|
export type TtpChapter = Chapter;
|
||||||
|
export type TtpSidebarItem = SidebarItem;
|
||||||
|
|
||||||
/** Volume sentinel for books without volumes. */
|
export async function getTtpMeta() {
|
||||||
const NO_VOLUME = 0;
|
return getBookMeta(CODE);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TtpMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: TtpChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TtpChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TtpIntroSegment = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
book: string;
|
|
||||||
order: number;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TtpChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Union type used in sidebar / table-of-contents navigation. */
|
|
||||||
export type TtpSidebarItem = {
|
|
||||||
/** 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;
|
|
||||||
}
|
}
|
||||||
|
export async function getTtpIntroSegments() {
|
||||||
/** Normalize paragraphs — handles both native arrays and stringified JSON. */
|
return getIntroSegments(CODE);
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getTtpIntroSegment(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getIntroSegment(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getTtpChapter(chapterNum: number) {
|
||||||
return result.map(sanitizeHtml);
|
return getChapter(CODE, String(chapterNum));
|
||||||
}
|
}
|
||||||
|
export async function getTtpContent(slug: string) {
|
||||||
// ---------------------------------------------------------------------------
|
return getContent(CODE, slug);
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Book-level metadata (title, author, chapter list, etc.)
|
|
||||||
*/
|
|
||||||
export async function getTtpMeta(): Promise<TtpMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as TtpMeta;
|
|
||||||
}
|
}
|
||||||
|
export async function getTtpSidebarItems() {
|
||||||
// ---------------------------------------------------------------------------
|
return getSidebarItems(CODE);
|
||||||
// Intro segments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All intro segments, ordered by their `order` field.
|
|
||||||
*/
|
|
||||||
export async function getTtpIntroSegments(): Promise<TtpIntroSegment[]> {
|
|
||||||
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 getTtpIntroSegment(
|
|
||||||
slug: string,
|
|
||||||
): Promise<TtpIntroSegment | null> {
|
|
||||||
// DB stores with "intro-" prefix; accept either form
|
|
||||||
const dbSlug = slug.startsWith("intro-") ? slug : `intro-${slug}`;
|
|
||||||
|
|
||||||
const doc = await prisma.proseChapter.findUnique({
|
|
||||||
where: {
|
|
||||||
book_volume_slug: { book: BOOK, volume: NO_VOLUME, slug: dbSlug },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: doc.slug,
|
|
||||||
title: doc.title,
|
|
||||||
type: doc.type,
|
|
||||||
book: doc.book,
|
|
||||||
order: doc.order,
|
|
||||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A numbered chapter (1–5).
|
|
||||||
*/
|
|
||||||
export async function getTtpChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<TtpChapter | 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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Unified content accessor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content by URL slug.
|
|
||||||
*
|
|
||||||
* "intro-front-pages" → intro segment
|
|
||||||
* "1" … "5" → chapter
|
|
||||||
*/
|
|
||||||
export async function getTtpContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<TtpChapter | TtpIntroSegment | null> {
|
|
||||||
if (slug.startsWith("intro-")) {
|
|
||||||
return getTtpIntroSegment(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 5) {
|
|
||||||
return getTtpChapter(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full sidebar navigation list.
|
|
||||||
*
|
|
||||||
* Order: front-pages → chapters 1–5
|
|
||||||
*/
|
|
||||||
export async function getTtpSidebarItems(): Promise<TtpSidebarItem[]> {
|
|
||||||
const [meta, allDocs] = await Promise.all([
|
|
||||||
getTtpMeta(),
|
|
||||||
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: TtpSidebarItem[] = [];
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +1,29 @@
|
|||||||
/**
|
import {
|
||||||
* Data access layer for Vaiśiṣṭyāṣṭakam (VA).
|
getBookMeta,
|
||||||
*
|
getChapter,
|
||||||
* Reads from MongoDB via Prisma.
|
getContent,
|
||||||
* VA is a poetic work with 8 chapters (Vaiśiṣṭyas) and no intro segments.
|
getSidebarItems,
|
||||||
*
|
type BookMeta,
|
||||||
* Collections used:
|
type Chapter,
|
||||||
* - book_meta (via Prisma BookMeta model)
|
type SidebarItem,
|
||||||
* - prose_chapters (via Prisma ProseChapter model)
|
} from "@/lib/books";
|
||||||
*/
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
const CODE = "va";
|
||||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export type VaMeta = BookMeta;
|
||||||
// Constants
|
export type VaChapterEntry = { number: number; title: string };
|
||||||
// ---------------------------------------------------------------------------
|
export type VaChapter = Chapter;
|
||||||
|
export type VaSidebarItem = SidebarItem;
|
||||||
|
|
||||||
const BOOK = "va";
|
export async function getVaMeta() {
|
||||||
|
return getBookMeta(CODE);
|
||||||
/** Volume sentinel for books without volumes. */
|
|
||||||
const NO_VOLUME = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type VaMeta = {
|
|
||||||
book: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
year: number;
|
|
||||||
description: string;
|
|
||||||
totalChapters: number;
|
|
||||||
chapters: VaChapterEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VaChapterEntry = {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VaChapter = {
|
|
||||||
book: string;
|
|
||||||
chapter: number | null;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
slug?: string;
|
|
||||||
paragraphs: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Used in sidebar / table-of-contents navigation. */
|
|
||||||
export type VaSidebarItem = {
|
|
||||||
slug: string;
|
|
||||||
label: string;
|
|
||||||
type: string;
|
|
||||||
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 getVaChapter(chapterNum: number) {
|
||||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
return getChapter(CODE, String(chapterNum));
|
||||||
let result: string[];
|
|
||||||
|
|
||||||
if (typeof paragraphs === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(paragraphs);
|
|
||||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
|
||||||
} catch {
|
|
||||||
result = [paragraphs];
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(paragraphs)) {
|
export async function getVaContent(slug: string) {
|
||||||
result = paragraphs as string[];
|
return getContent(CODE, slug);
|
||||||
} else {
|
|
||||||
result = [];
|
|
||||||
}
|
}
|
||||||
|
export async function getVaSidebarItems() {
|
||||||
return result.map(sanitizeHtml);
|
return getSidebarItems(CODE);
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Book meta
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function getVaMeta(): Promise<VaMeta | null> {
|
|
||||||
const meta = await prisma.bookMeta.findUnique({ where: { book: BOOK } });
|
|
||||||
if (!meta) return null;
|
|
||||||
return meta.data as unknown as VaMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chapters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function getVaChapter(
|
|
||||||
chapterNum: number,
|
|
||||||
): Promise<VaChapter | null> {
|
|
||||||
if (chapterNum < 1 || chapterNum > 8 || !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
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function getVaContent(
|
|
||||||
slug: string,
|
|
||||||
): Promise<VaChapter | null> {
|
|
||||||
const num = Number(slug);
|
|
||||||
if (!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 8) {
|
|
||||||
return getVaChapter(num);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sidebar navigation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function getVaSidebarItems(): Promise<VaSidebarItem[]> {
|
|
||||||
const meta = await getVaMeta();
|
|
||||||
if (!meta) return [];
|
|
||||||
|
|
||||||
return meta.chapters.map((ch) => ({
|
|
||||||
slug: String(ch.number),
|
|
||||||
label: `${ch.number}. ${ch.title}`,
|
|
||||||
type: "chapter",
|
|
||||||
chapter: ch.number,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-11440
File diff suppressed because it is too large
Load Diff
+1
-5
@@ -9,17 +9,13 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1045.0",
|
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
"@prisma/client": "^6.19.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.4.2",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"mongodb": "^7.1.1",
|
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "^6.19.3",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"shadcn": "^4.2.0",
|
"shadcn": "^4.2.0",
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import "dotenv/config";
|
|
||||||
import { defineConfig } from "prisma/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
schema: "prisma/schema.prisma",
|
|
||||||
datasource: {
|
|
||||||
url: process.env["DATABASE_URL"]!,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "mongodb"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Synonym — embedded document inside Verse
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
type Synonym {
|
|
||||||
word String
|
|
||||||
meanings String[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Verse — unified model for BG, SB, and CC verses
|
|
||||||
//
|
|
||||||
// book: "bg" | "sb" | "cc"
|
|
||||||
// division: null for BG, canto number string for SB ("1"-"10"),
|
|
||||||
// līlā key for CC ("adi", "madhya", "antya")
|
|
||||||
// chapter: chapter number
|
|
||||||
// verse: verse number (first verse number for multi-verse entries)
|
|
||||||
// verseEnd: last verse number for multi-verse entries (e.g. 18 for "16-18")
|
|
||||||
// reference: display reference string (e.g. "Bg 1.1", "SB 1.1.1", "Cc Ādi 1.1")
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model Verse {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
book String // "bg" | "sb" | "cc"
|
|
||||||
division String? // null for BG; canto for SB; lila for CC
|
|
||||||
chapter Int
|
|
||||||
verse Int
|
|
||||||
verseEnd Int?
|
|
||||||
reference String
|
|
||||||
bookTitle String
|
|
||||||
devanagari String
|
|
||||||
bengali String? // only CC has Bengali
|
|
||||||
transliteration String
|
|
||||||
synonyms Synonym[]
|
|
||||||
translation String
|
|
||||||
purport String[]
|
|
||||||
|
|
||||||
@@unique([book, division, chapter, verse])
|
|
||||||
@@index([book])
|
|
||||||
@@index([book, division])
|
|
||||||
@@index([book, division, chapter])
|
|
||||||
@@map("verses")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// BookMeta — stores the intro.json data for each book
|
|
||||||
//
|
|
||||||
// The `data` field stores the entire intro JSON as a flexible JSON blob,
|
|
||||||
// since BG/SB/CC each have different structures (chapters vs cantos vs lilas).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model BookMeta {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
book String @unique // "bg" | "sb" | "cc"
|
|
||||||
data Json
|
|
||||||
|
|
||||||
@@map("book_meta")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ProseChapter — for prose books like Kṛṣṇa Book (no verse structure)
|
|
||||||
//
|
|
||||||
// book: "kb"
|
|
||||||
// volume: volume number (1 or 2 for KB)
|
|
||||||
// chapter: chapter number within the volume
|
|
||||||
// title: chapter title
|
|
||||||
// type: "chapter" | "intro-segment"
|
|
||||||
// slug: URL-friendly identifier (e.g. "01", "preface", "words-from-apple")
|
|
||||||
// order: sort order (chapter number for chapters, custom for intro segments)
|
|
||||||
// paragraphs: array of HTML paragraph strings
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model ProseChapter {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
book String // "kb"
|
|
||||||
volume Int
|
|
||||||
chapter Int? // null for intro segments
|
|
||||||
title String
|
|
||||||
type String // "chapter" | "intro-segment"
|
|
||||||
slug String
|
|
||||||
order Int
|
|
||||||
paragraphs String[]
|
|
||||||
|
|
||||||
@@unique([book, volume, slug])
|
|
||||||
@@index([book])
|
|
||||||
@@index([book, volume])
|
|
||||||
@@index([book, volume, type])
|
|
||||||
@@map("prose_chapters")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Essay — standalone essays (not part of any book's chapter structure)
|
|
||||||
//
|
|
||||||
// slug: URL-friendly identifier (e.g. "krishna-reservoir-of-pleasure")
|
|
||||||
// title: essay title
|
|
||||||
// author: author name
|
|
||||||
// year: publication year
|
|
||||||
// group: optional grouping key (e.g. "two-essays" for essays published together)
|
|
||||||
// order: sort order within a group
|
|
||||||
// paragraphs: array of HTML paragraph strings
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model Essay {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
slug String @unique
|
|
||||||
title String
|
|
||||||
author String
|
|
||||||
year Int?
|
|
||||||
group String?
|
|
||||||
order Int
|
|
||||||
paragraphs String[]
|
|
||||||
|
|
||||||
@@index([group])
|
|
||||||
@@map("essays")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// OtherDocumentMeta — metadata for miscellaneous documents
|
|
||||||
//
|
|
||||||
// book: slug identifier (e.g. "analysis-bg", "real-peace-formula")
|
|
||||||
// data: JSON blob containing title, author, year, description, chapters list
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model OtherDocumentMeta {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
book String @unique
|
|
||||||
data Json
|
|
||||||
|
|
||||||
@@map("other_document_meta")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// OtherDocumentChapter — chapters within other documents
|
|
||||||
//
|
|
||||||
// book: slug identifier matching OtherDocumentMeta.book
|
|
||||||
// chapter: chapter number
|
|
||||||
// title: chapter title
|
|
||||||
// type: "chapter"
|
|
||||||
// slug: URL-friendly identifier (chapter number as string)
|
|
||||||
// order: sort order
|
|
||||||
// paragraphs: array of HTML paragraph strings
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model OtherDocumentChapter {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
book String
|
|
||||||
chapter Int
|
|
||||||
title String
|
|
||||||
type String
|
|
||||||
slug String
|
|
||||||
order Int
|
|
||||||
paragraphs String[]
|
|
||||||
|
|
||||||
@@unique([book, slug])
|
|
||||||
@@index([book])
|
|
||||||
@@map("other_document_chapters")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// JournalMeta — metadata for journals (Jaladuta Diary, The Beginning, etc.)
|
|
||||||
//
|
|
||||||
// book: slug identifier (e.g. "jaladuta-diary", "the-beginning")
|
|
||||||
// data: JSON blob containing title, author, year, description, chapters list
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model JournalMeta {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
book String @unique
|
|
||||||
data Json
|
|
||||||
|
|
||||||
@@map("journal_meta")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// JournalChapter — chapters within journals
|
|
||||||
//
|
|
||||||
// book: slug identifier matching JournalMeta.book
|
|
||||||
// chapter: chapter number
|
|
||||||
// title: chapter title
|
|
||||||
// type: "chapter" | "preface" | "introduction"
|
|
||||||
// slug: URL-friendly identifier (chapter number as string)
|
|
||||||
// order: sort order
|
|
||||||
// paragraphs: array of HTML paragraph strings
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// MagazineMeta — metadata for magazines (Back to Godhead, etc.)
|
|
||||||
//
|
|
||||||
// book: slug identifier (e.g. "btg")
|
|
||||||
// data: JSON blob containing title, author, description, volumes/issues list
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model MagazineMeta {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
book String @unique
|
|
||||||
data Json
|
|
||||||
|
|
||||||
@@map("magazine_meta")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// MagazineIssue — individual issues within a magazine
|
|
||||||
//
|
|
||||||
// book: slug identifier matching MagazineMeta.book
|
|
||||||
// volume: volume number
|
|
||||||
// part: part/issue number within the volume
|
|
||||||
// title: display title (e.g. "Volume 3, Part 5, May 5, 1956")
|
|
||||||
// slug: URL-friendly identifier (e.g. "v3-p5")
|
|
||||||
// order: sort order
|
|
||||||
// paragraphs: array of HTML paragraph strings (all articles concatenated)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
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[]
|
|
||||||
|
|
||||||
@@unique([book, slug])
|
|
||||||
@@index([book])
|
|
||||||
@@index([book, volume])
|
|
||||||
@@map("magazine_issues")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GlossaryEntry — individual glossary term + definition
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Letter — individual letters written by Śrīla Prabhupāda
|
|
||||||
//
|
|
||||||
// slug: unique identifier (e.g. "47-07-12-mahatma-gandhi", "68-01-15-hayagriva")
|
|
||||||
// recipients: array of individual recipient names (split from comma-separated)
|
|
||||||
// recipientDisplay: original recipient string for display (e.g. "Mukunda, Janaki")
|
|
||||||
// date: date string as written (e.g. "12 July, 1947")
|
|
||||||
// dateRef: reference code (e.g. "47-07-12")
|
|
||||||
// year: extracted year number for filtering/grouping
|
|
||||||
// month: extracted month number (1-12) for filtering
|
|
||||||
// day: extracted day number for filtering
|
|
||||||
// location: city/place where the letter was written
|
|
||||||
// salutation: the "Dear..." line
|
|
||||||
// paragraphs: array of HTML paragraph strings (letter body)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model Letter {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
slug String @unique
|
|
||||||
recipients String[]
|
|
||||||
recipientDisplay String
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Disciple — disciples initiated by Śrīla Prabhupāda
|
|
||||||
//
|
|
||||||
// initiatedName: spiritual name given at initiation (e.g. "Acyutananda")
|
|
||||||
// suffix: initiation suffix (e.g. "Dasa", "Devi Dasi", "Sw")
|
|
||||||
// initiationDate: date of initiation (set to 1st of the month when exact day unknown)
|
|
||||||
// initiationPlace: place of initiation (e.g. "New York", "London")
|
|
||||||
// karmiName: pre-initiation (birth) name, if known
|
|
||||||
// avatar: URL to profile photo, if available
|
|
||||||
// about: biographical notes
|
|
||||||
// tags: array of tags for categorization
|
|
||||||
// isVerified: whether the profile has been manually verified with exact dates etc.
|
|
||||||
// notes: internal notes (not shown in UI)
|
|
||||||
// verifiedBy: name/identifier of who verified this profile (not shown in UI)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model Disciple {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
initiatedName String
|
|
||||||
suffix String
|
|
||||||
initiationDate DateTime?
|
|
||||||
initiationPlace String?
|
|
||||||
karmiName String?
|
|
||||||
avatar String?
|
|
||||||
about String?
|
|
||||||
tags String[]
|
|
||||||
isVerified Boolean @default(false)
|
|
||||||
notes String?
|
|
||||||
verifiedBy String?
|
|
||||||
|
|
||||||
@@index([initiatedName])
|
|
||||||
@@index([initiationPlace])
|
|
||||||
@@index([initiationDate])
|
|
||||||
@@index([suffix])
|
|
||||||
@@index([tags])
|
|
||||||
@@map("disciples")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ContentBlock — embedded document for rich text content within transcriptions
|
|
||||||
//
|
|
||||||
// text: the actual text content (may contain inline HTML for bold/italic)
|
|
||||||
// format: formatting hint — "paragraph" | "heading" | "quote" | "verse" | "note"
|
|
||||||
// alignment: text alignment — "left" | "center" | "right" (default: left)
|
|
||||||
// speaker: who is speaking this block (for dialogues — e.g., "Prabhupāda", "Devotee")
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
type ContentBlock {
|
|
||||||
text String
|
|
||||||
format String @default("paragraph")
|
|
||||||
alignment String @default("left")
|
|
||||||
speaker String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Transcription — lectures and conversations of Śrīla Prabhupāda
|
|
||||||
//
|
|
||||||
// slug: URL-friendly unique identifier (e.g., "720911-lecture-los-angeles")
|
|
||||||
// name: display title (e.g., "Lecture on Bhagavad-gītā 2.13")
|
|
||||||
// type: "lecture" | "conversation"
|
|
||||||
// date: date of the transcription
|
|
||||||
// year: extracted year for filtering/grouping
|
|
||||||
// month: extracted month (1-12) for filtering
|
|
||||||
// day: extracted day for filtering
|
|
||||||
// place: location where it was delivered (e.g., "Los Angeles", "Vṛndāvana")
|
|
||||||
// context: additional context (e.g., "Morning Walk", "Room Conversation", "Arrival Address")
|
|
||||||
// reference: scripture reference if applicable (e.g., "BG 2.13", "SB 1.2.6")
|
|
||||||
// summary: brief description of the content (for OG images, search results)
|
|
||||||
// content: array of ContentBlock embedded documents (the actual transcription)
|
|
||||||
// tags: array of tags for categorization (e.g., ["science", "reincarnation", "guru"])
|
|
||||||
// participants: array of named participants (e.g., ["Prabhupāda", "Bob Cohen", "Śyāmasundara"])
|
|
||||||
// language: primary language of the transcription (default: "en")
|
|
||||||
// source: origin of the transcription (e.g., "Vedabase", "BBT Archives")
|
|
||||||
// durationMinutes: approximate duration in minutes (if known)
|
|
||||||
// isPublished: whether the transcription is ready for public display
|
|
||||||
// notes: internal editorial notes (not shown in UI)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
model Transcription {
|
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
||||||
slug String @unique
|
|
||||||
name String
|
|
||||||
type String // "lecture" | "conversation"
|
|
||||||
date String? // date as written (e.g., "September 11, 1972")
|
|
||||||
year Int?
|
|
||||||
month Int?
|
|
||||||
day Int?
|
|
||||||
place String?
|
|
||||||
context String?
|
|
||||||
reference String?
|
|
||||||
summary String?
|
|
||||||
content ContentBlock[]
|
|
||||||
tags String[]
|
|
||||||
participants String[]
|
|
||||||
language String @default("en")
|
|
||||||
source String?
|
|
||||||
durationMinutes Int?
|
|
||||||
audioUrl String?
|
|
||||||
isPublished Boolean @default(false)
|
|
||||||
notes String?
|
|
||||||
|
|
||||||
@@index([type])
|
|
||||||
@@index([year])
|
|
||||||
@@index([year, month])
|
|
||||||
@@index([place])
|
|
||||||
@@index([tags])
|
|
||||||
@@index([reference])
|
|
||||||
@@index([participants])
|
|
||||||
@@index([isPublished])
|
|
||||||
@@map("transcriptions")
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user