Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd1d2e724 | ||
|
|
179b7d8862 | ||
|
|
7294d9e15b | ||
|
|
e3df40559b | ||
|
|
9a3056fbd8 | ||
|
|
db8deb232b | ||
|
|
730efb1bb3 | ||
|
|
0106d8cee2 | ||
|
|
d917b3ea7d | ||
|
|
7c07b33e4b | ||
|
|
96a83fe1cb | ||
|
|
befcc44980 | ||
|
|
e45d5eb02c | ||
|
|
09fbcf93ed | ||
|
|
4cbb146bc1 | ||
|
|
bf1ff47d3e |
+152
-252
@@ -15,10 +15,10 @@ Content is sourced from the pre-1980s Vedabase, extracted and indexed into Mongo
|
||||
- **shadcn/ui** (base-nova style, lucide icons) — components: Button, Card, Badge, Tooltip, Input, Drawer
|
||||
- **next-themes** for dark/light/system mode
|
||||
- **vaul** for mobile drawer navigation
|
||||
- **Prisma v6** with MongoDB (provider `prisma-client-js`)
|
||||
- **MongoDB** native driver (`mongodb` package) for seeding (Prisma requires replica set for writes)
|
||||
- **Go BFF API** — all data fetched via `lib/bff.ts` HTTP client calling the Go BFF server (no direct database access)
|
||||
- **framer-motion** for audio player animations (spring enter/exit, minimize/expand transitions, audio wave bars)
|
||||
- **Typesense** (via BFF) — typo-tolerant full-text search across all content
|
||||
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
|
||||
- **Database**: MongoDB at `DATABASE_URL` env variable with `?authSource=admin`
|
||||
|
||||
---
|
||||
|
||||
@@ -70,9 +70,13 @@ knowledgebase/
|
||||
│ │ ├── place/[place]/page.tsx → Place listing
|
||||
│ │ ├── type/[type]/page.tsx → Type listing (lecture/conversation), year sidebar
|
||||
│ │ └── [slug]/page.tsx → Individual transcription reading page
|
||||
│ ├── search/ → Search page (server + client components)
|
||||
│ │ ├── page.tsx → Server component (URL-driven state)
|
||||
│ │ └── search-content.tsx → Client component (input, filters, results)
|
||||
│ └── api/
|
||||
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
|
||||
│ ├── books/ → Book/verse API routes
|
||||
│ ├── search/route.ts → BFF search proxy (Typesense via /api/v1/search)
|
||||
│ └── glossary/route.ts → Glossary API
|
||||
├── components/
|
||||
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer)
|
||||
@@ -87,6 +91,7 @@ knowledgebase/
|
||||
│ ├── page-header.tsx → Shared header with ReadingSettings (max-w-5xl)
|
||||
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
|
||||
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
|
||||
│ ├── header-search.tsx → Header search button with framer-motion expand animation
|
||||
│ ├── site-footer.tsx → Global footer (About + Home links)
|
||||
│ ├── audio-player/ → Site-wide floating audio player
|
||||
│ │ ├── audio-context.tsx → React context + provider, manages <audio> element
|
||||
@@ -96,23 +101,21 @@ knowledgebase/
|
||||
│ ├── logo.tsx → Theme-aware logo component
|
||||
│ └── theme-provider.tsx → next-themes wrapper
|
||||
├── lib/
|
||||
│ ├── db.ts → Prisma client singleton
|
||||
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary)
|
||||
│ ├── bff.ts → BFF API client (typed fetch wrapper: bff<T>(), bffList<T>(), bffOrThrow<T>())
|
||||
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary) — calls BFF API
|
||||
│ ├── books.ts → Generic book data layer (replaces 29 individual book files)
|
||||
│ ├── constants.ts → Shared constants (BOOK_NAMES, CC_LILAS)
|
||||
│ ├── og.ts → OG image metadata builders (ogForVerse, ogForLetter, etc.)
|
||||
│ ├── text-highlight.ts → Text fragment encode/decode, highlight matching
|
||||
│ ├── source-reference.ts → Source reference from URL (for copy attribution)
|
||||
│ ├── disciples.ts → Disciples data access (by letter, place, year, search)
|
||||
│ ├── letters.ts → Letters data access (by year, recipient, location)
|
||||
│ ├── transcriptions.ts → Transcriptions data access (reads from Prisma/MongoDB)
|
||||
│ ├── disciples.ts → Disciples data access — calls BFF API
|
||||
│ ├── letters.ts → Letters data access (by year, recipient, location) — calls BFF API
|
||||
│ ├── transcriptions.ts → Transcriptions data access — calls BFF API
|
||||
│ ├── utils.ts → cn() helper
|
||||
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.)
|
||||
│ ├── other-documents.ts → Other Documents data access
|
||||
│ ├── journals.ts → Journals data access
|
||||
│ └── magazines.ts → Magazines data access
|
||||
├── prisma/
|
||||
│ └── schema.prisma → All models (Verse, BookMeta, ProseChapter, Essay, Letter,
|
||||
│ GlossaryEntry, Disciple, Transcription, etc.)
|
||||
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.) — calls BFF API
|
||||
│ ├── other-documents.ts → Other Documents data access — calls BFF API
|
||||
│ ├── journals.ts → Journals data access — calls BFF API
|
||||
│ └── magazines.ts → Magazines data access — calls BFF API
|
||||
└── public/
|
||||
├── fonts/Noto_Sans/static/ → NotoSans-Regular.ttf + NotoSans-Bold.ttf (OG images)
|
||||
├── images/ → OG card image (Prabhupāda)
|
||||
@@ -132,232 +135,50 @@ extractor/ → Extraction & seeding tools (not deployed)
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (Prisma + MongoDB)
|
||||
## Data Architecture
|
||||
|
||||
```prisma
|
||||
type Synonym {
|
||||
word String
|
||||
meanings String[]
|
||||
}
|
||||
The knowledgebase **never connects to MongoDB directly**. All data is served by the **Go BFF API** server.
|
||||
|
||||
model Verse {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String // "bg" | "sb" | "cc" | "sb1962" | "iso" | "noi"
|
||||
division String? // null for BG/SB1962/ISO/NOI; canto for SB; lila for CC
|
||||
chapter Int
|
||||
verse Int
|
||||
verseEnd Int?
|
||||
reference String
|
||||
bookTitle String
|
||||
devanagari String
|
||||
bengali String? // only CC has Bengali
|
||||
transliteration String
|
||||
synonyms Synonym[]
|
||||
translation String
|
||||
purport String[]
|
||||
### BFF Server
|
||||
|
||||
@@unique([book, division, chapter, verse])
|
||||
@@index([book])
|
||||
@@index([book, division])
|
||||
@@index([book, division, chapter])
|
||||
@@map("verses")
|
||||
}
|
||||
- **URL**: Configured via `BFF_URL` environment variable (default: `http://localhost:8080`)
|
||||
- The BFF reads from MongoDB — the knowledgebase frontend has no database driver or connection string
|
||||
- All API endpoints live under `/api/v1/...` on the BFF server
|
||||
|
||||
model BookMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
### BFF Client (`lib/bff.ts`)
|
||||
|
||||
@@map("book_meta")
|
||||
}
|
||||
The typed fetch wrapper provides three helpers:
|
||||
|
||||
model ProseChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
volume Int // 0 = no volumes (sentinel for non-volume books)
|
||||
chapter Int? // null for intro segments
|
||||
title String
|
||||
type String // "chapter" | "intro-segment" | "conclusion"
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
- **`bff<T>(path, options?)`** — Fetch JSON from the BFF. Returns `null` on 404, throws `BffError` on other failures.
|
||||
- **`bffList<T>(path, options?)`** — Fetch a list. Returns `[]` on 404 (instead of null).
|
||||
- **`bffOrThrow<T>(path, options?)`** — Fetch JSON, throwing on 404. Use when a missing resource should be an error.
|
||||
|
||||
@@unique([book, volume, slug])
|
||||
@@index([book])
|
||||
@@index([book, volume])
|
||||
@@index([book, volume, type])
|
||||
@@map("prose_chapters")
|
||||
}
|
||||
Options include `params` (query parameters), `revalidate` (Next.js ISR seconds, default 60), and `tags` (cache tags).
|
||||
|
||||
model Essay {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
slug String @unique
|
||||
title String
|
||||
author String
|
||||
year Int?
|
||||
group String? // e.g., "two-essays"
|
||||
order Int
|
||||
paragraphs String[]
|
||||
### Response Envelope
|
||||
|
||||
@@index([group])
|
||||
@@map("essays")
|
||||
}
|
||||
BFF responses use a `{ success: true, data: ... }` envelope. The `bff()` client auto-unwraps this — callers receive the inner `data` value directly.
|
||||
|
||||
model OtherDocumentMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
### Case Mapping
|
||||
|
||||
@@map("other_document_meta")
|
||||
}
|
||||
The BFF uses **snake_case** JSON field names (Go convention). Each frontend data layer file (`lib/letters.ts`, `lib/transcriptions.ts`, `lib/books.ts`, etc.) maps BFF responses to **camelCase** TypeScript types used by React components.
|
||||
|
||||
model OtherDocumentChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
chapter Int
|
||||
title String
|
||||
type String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
### Data Layer Files
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@map("other_document_chapters")
|
||||
}
|
||||
Individual `lib/*.ts` files are thin wrappers that:
|
||||
1. Call a BFF endpoint via `bff()` / `bffList()` / `bffOrThrow()`
|
||||
2. Map the snake_case response to camelCase frontend types
|
||||
3. Export typed functions consumed by Next.js server components
|
||||
|
||||
model JournalMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
|
||||
@@map("journal_meta")
|
||||
}
|
||||
|
||||
model JournalChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
chapter Int
|
||||
title String
|
||||
type String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@map("journal_chapters")
|
||||
}
|
||||
|
||||
model MagazineMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
|
||||
@@map("magazine_meta")
|
||||
}
|
||||
|
||||
model MagazineIssue {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
volume Int
|
||||
part Int
|
||||
title String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
paragraphsJson String? // JSON string fallback for large issues (BSON wire protocol workaround)
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@index([book, volume])
|
||||
@@map("magazine_issues")
|
||||
}
|
||||
|
||||
model Letter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
slug String @unique
|
||||
recipients String[] // array of individual recipient names
|
||||
recipientDisplay String // original combined string for display
|
||||
date String?
|
||||
dateRef String?
|
||||
year Int?
|
||||
month Int?
|
||||
day Int?
|
||||
location String?
|
||||
salutation String?
|
||||
paragraphs String[]
|
||||
|
||||
@@index([year])
|
||||
@@index([recipients])
|
||||
@@index([location])
|
||||
@@index([year, month])
|
||||
@@map("letters")
|
||||
}
|
||||
|
||||
model GlossaryEntry {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
term String
|
||||
definition String
|
||||
definitionHtml String
|
||||
|
||||
@@unique([term])
|
||||
@@map("glossary")
|
||||
}
|
||||
|
||||
model Disciple {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
initiatedName String
|
||||
suffix String // "Dasa" | "Devi Dasi" | "Sw"
|
||||
initiationDate DateTime? // 1st of the month (exact day unknown unless verified)
|
||||
initiationPlace String?
|
||||
karmiName String? // pre-initiation (birth) name
|
||||
avatar String? // URL to profile photo
|
||||
about String? // biographical notes
|
||||
tags String[]
|
||||
isVerified Boolean @default(false)
|
||||
|
||||
@@index([initiatedName])
|
||||
@@index([initiationPlace])
|
||||
@@index([initiationDate])
|
||||
@@index([suffix])
|
||||
@@index([tags])
|
||||
@@map("disciples")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
```
|
||||
Key data layers and their BFF endpoint prefixes:
|
||||
- `lib/api.ts` → `/api/v1/books/*`, `/api/v1/glossary/*` — Books, verses, prose chapters, glossary
|
||||
- `lib/books.ts` → `/api/v1/books/{code}/*` — Generic book data layer (replaces 29 individual per-book files)
|
||||
- `lib/letters.ts` → `/api/v1/letters/*` — Letters (by year, recipient, location)
|
||||
- `lib/transcriptions.ts` → `/api/v1/transcriptions/*` — Transcriptions (by year, place, type)
|
||||
- `lib/disciples.ts` → `/api/v1/disciples/*` — Disciples directory
|
||||
- `lib/other-documents.ts` → `/api/v1/other-documents/*` — Other Documents
|
||||
- `lib/journals.ts` → `/api/v1/journals/*` — Journals
|
||||
- `lib/magazines.ts` → `/api/v1/magazines/*` — Magazines
|
||||
|
||||
---
|
||||
|
||||
@@ -503,7 +324,7 @@ Seed script: `npx tsx scripts/seed-disciples.ts`
|
||||
**Schema notes**:
|
||||
- `recipients` is an array of individual names (e.g., `["Mukunda", "Janaki"]`)
|
||||
- `recipientDisplay` is the original combined string (e.g., `"Mukunda, Janaki"`)
|
||||
- Searching for a recipient uses Prisma `{ has: name }` to match any letter where that person appears
|
||||
- Searching for a recipient filters via the BFF endpoint (matches any letter where that person appears)
|
||||
- Multi-recipient groups are split by `,`, `;`, ` and `, ` & `
|
||||
- Trailing punctuation (`:`, `,`, `.`) cleaned from names
|
||||
|
||||
@@ -518,9 +339,7 @@ Seed script: `npx tsx scripts/seed-disciples.ts`
|
||||
- **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`
|
||||
Data layer: `lib/transcriptions.ts` (calls BFF API, NOT local JSON files or direct MongoDB)
|
||||
|
||||
### Other
|
||||
|
||||
@@ -716,11 +535,17 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
|
||||
### Reading Settings (accessibility)
|
||||
- **Theme**: Light / Dark / System toggle
|
||||
- **Palette**: 3 light + 3 dark colour palettes, each selectable from a swatch grid in the settings panel
|
||||
- Light: **Aged Parchment** (warm cream/saffron — default), **Tulasī Garden** (sage green/emerald), **Yamunā Mist** (sky-blue/cerulean)
|
||||
- Dark: **Ancient Manuscript** (warm brown/saffron — default), **Nīlamaṇi** (deep navy/sky-blue — Kṛṣṇa's "blue jewel"), **Vraja Twilight** (deep wine/rose-red)
|
||||
- Swatches in the settings panel show only the palettes for the currently resolved theme (uses `resolvedTheme` from next-themes, so System mode resolves correctly)
|
||||
- Each palette defines a full set of CSS variables (background, foreground, primary, card, muted, accent, border, etc.) so the entire UI adapts cohesively
|
||||
- Palette CSS lives in `app/globals.css` under `:root:not(.dark)[data-light-palette="..."]` and `.dark[data-dark-palette="..."]` selectors. The `:not(.dark)` scoping prevents light-palette attribute rules from overriding `.dark` (an attribute selector has higher specificity than a plain class selector)
|
||||
- **Serif Font**: Toggle to switch reading content to Georgia/serif
|
||||
- **Font Size**: Slider (XS → XXL) using CSS `zoom` on `main` and `aside`
|
||||
- **Justify Text**: Toggle for justified text alignment on reading content
|
||||
- Settings persist in `localStorage` under `spsays_reading_prefs`
|
||||
- Applied via CSS classes on `<html>` element
|
||||
- Settings persist in `localStorage` under `spsays_reading_prefs` (now includes `lightPalette` and `darkPalette` fields)
|
||||
- Applied via CSS classes (`reading-serif`, `reading-fs-*`, `reading-justified`) AND data attributes (`data-light-palette`, `data-dark-palette`) on `<html>` element
|
||||
|
||||
### Mobile Navigation
|
||||
- Left-slide drawer (vaul) triggered by a menu button next to the Previous button
|
||||
@@ -728,14 +553,80 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
- Shows the full sidebar navigation grouped by section
|
||||
- Implemented in all 24+ reader pages via shared `MobileNav` component
|
||||
|
||||
### Page Header (`components/page-header.tsx`)
|
||||
- Shared client component used on every page — wraps the title/breadcrumb area and the right-side actions (search button + reading settings)
|
||||
- **Mobile layout** uses CSS `float-right` on the actions block so the title text genuinely wraps around them on the first line and uses the **full container width** on every subsequent line. (CSS `padding-right` was previously used but applies the same gap to every line, leaving an empty column down the right edge.)
|
||||
- When the header search is expanded on mobile, the actions block drops the float, the logo/title area is hidden via `max-md:hidden`, and the search bar fills the full header width edge-to-edge (no `ml-2` margin in the expanded state)
|
||||
- **Desktop layout** is unchanged — standard `md:flex md:items-start md:justify-between md:gap-4` with the actions block on the right (`md:order-1 md:shrink-0`)
|
||||
- Reads a sessionStorage flag (`spsays_from_search`) on first render via a lazy `useState` initialiser; if set, renders a “← Back to search results” button that calls `router.back()` and **hides the page's own first-child `<Link>` breadcrumb** via `[&>a:first-child]:hidden` so there's never a double-arrow stack
|
||||
- The lazy initialiser also clears the flag on read (one-shot), so navigating from the result page to a subsequent page (e.g. next verse) doesn't show the search return button again
|
||||
|
||||
### Search
|
||||
|
||||
**Search** (`/search` page) — Typo-tolerant, diacritic-insensitive full-text search across all content.
|
||||
|
||||
- **Powered by Typesense** via the BFF (`/api/v1/search` proxied through `/api/search`)
|
||||
- **9 indexed content types** combined into 8 user-facing filters: Books (verses + prose), Letters, Transcriptions, Glossary, Essays, Journals, Magazines, Other Documents
|
||||
- **Books filter** combines verses and prose into one tab (sends `type=verses,prose` to the BFF)
|
||||
- **Multi-select filters** — clicking multiple type tabs combines them (e.g., Books + Letters); clicking "All" clears all filters
|
||||
- **URL state** — `q`, `type`, and `page` are synced to the URL (e.g. `/search?q=foo&type=books,letters&page=2`), so any search is shareable and bookmarkable
|
||||
- **Pagination** — appears both above the results (compact: prev/next + page count) and below them
|
||||
- **Result cards** — the entire card is a clickable `Link` that navigates to the source page
|
||||
- **Header search** — `HeaderSearchButton` on every page header expands into a search input on click; the `/` keyboard shortcut opens the search input from any page
|
||||
- **300ms debounce** on input
|
||||
- **Highlighted snippets** — matched text wrapped in `<mark>` tags
|
||||
- **Typo tolerance** — "Krishna", "Krsna", "Krishn" all find Kṛṣṇa results
|
||||
- **Diacritic insensitivity** — `_ascii` shadow fields indexed alongside originals (e.g., "Yashodanandana" matches "Yaśodanandana")
|
||||
- **Prefix matching** — partial words like "sarv dharman parit" match `sarva-dharmān parityajya`
|
||||
- **Hyphenated terms** — every collection (not just verses) has `token_separators: ["-"]`, so `Dīkṣā-guru` indexes as two tokens (`dīkṣā` + `guru`) and a query for "diksa guru" matches it cleanly
|
||||
- **Stopwords** — a server-side Typesense stopwords set (`common`) strips conversational tokens like `letter`, `to`, `of`, `essay`, `magazine`, etc. from queries so they don't drown out specific intent. e.g. "letter to tusta krsna" effectively searches for "tusta krsna" and surfaces actual letters to that recipient instead of any letter whose body contains "letter" + "to"
|
||||
- **Exact > prefix > typo ranking** — `prioritize_exact_match=true` + `prioritize_token_position=true` so "karma" outranks "karmic", and exact recipient matches outrank typo matches
|
||||
- **Glossary results** — glossary pages read `?term=` from the URL so a search result lands on the correct term (with diacritic-insensitive matching)
|
||||
- **Defensive null handling** — the frontend always treats a null `results` payload as an empty array
|
||||
- Files: `app/search/page.tsx`, `app/search/search-content.tsx`, `app/api/search/route.ts`, `components/header-search.tsx`
|
||||
|
||||
**Header search behaviour.** When the user submits a search from the inline header input, `HeaderSearchButton` automatically appends a `type` query param matching the section they're currently in (via `usePathname()`):
|
||||
|
||||
| Current path | Search URL gets | Active tab |
|
||||
|---|---|---|
|
||||
| `/books/**` | `type=books` | Books |
|
||||
| `/letters/**` | `type=letters` | Letters |
|
||||
| `/transcriptions/**` | `type=transcriptions` | Transcriptions |
|
||||
| `/essays/**` | `type=essays` | Essays |
|
||||
| `/glossary/**` | `type=glossary` | Glossary |
|
||||
| `/journals/**` | `type=journals` | Journals |
|
||||
| `/magazines/**` | `type=magazines` | Magazines |
|
||||
| `/other-documents/**` | `type=other_documents` | Other Documents |
|
||||
| `/`, `/search`, `/about` | no `type` param | All |
|
||||
|
||||
**Header search expand UX.** The collapsed icon expands into a 400px-wide bar on desktop (capped via `md:max-w-100`, animated to `width: 100%` of its container by framer-motion) and a full-width bar on mobile. The bar has a “Close” X on the left, the text input in the middle, and a primary-coloured “Search ⏎” submit button on the right — the `CornerDownLeft` icon hints that Enter also submits. Pressing Esc collapses. Spring animation on enter, instant collapse (no exit animation, no AnimatePresence — the previous use of `AnimatePresence mode="popLayout"` caused a backwards-shrink flash because `popLayout` pulled the exiting element out of flow and ran a layout-level FLIP).
|
||||
|
||||
**Back to search results.** When a user clicks a result card, `ResultCard`'s `onClick` writes a one-shot `spsays_from_search` flag to sessionStorage. On the destination page, `PageHeader` reads (and consumes) this flag in a lazy `useState` initialiser; if present, it replaces the page's own breadcrumb link with a “← Back to search results” button that calls `router.back()`. Client-side navigation preserves `AudioPlayerProvider` so audio playback continues uninterrupted.
|
||||
|
||||
**Search-result highlight.** Each result card builds its destination URL through `buildSearchResultUrl(result.url, result.snippet)` from `lib/text-highlight.ts`. This extracts the text inside the snippet's `<mark>` tags (from first mark to last mark, HTML stripped), encodes it via the existing `encodeTextFragment()` helper, and appends it as `?hl=...`. The destination page's existing `TextHighlighter` then finds and highlights the matched phrase — reusing the exact same `?hl=` infrastructure as share links, with no changes to `TextHighlighter` itself.
|
||||
|
||||
**Search relevance.** Search is tuned to find verses where the verse text itself contains the query, not commentaries that cite it. Verse purports are intentionally excluded from the default search so results stay focused on primary sources. In practice this means searching for `sarva dharman parityajya` returns BG 18.66 first (the actual verse) rather than the many commentaries that quote it.
|
||||
|
||||
### Per-Page Filters
|
||||
- Books page: diacritic-aware search (typing "Krsna" matches "Kṛṣṇa")
|
||||
- Glossary page: full-text search across terms and definitions
|
||||
- Uses Unicode NFD normalization + manual IAST character stripping
|
||||
|
||||
### Color Palette
|
||||
- **Light mode ("Aged Parchment")**: Warm cream backgrounds, deep sepia text, saffron/ochre primary
|
||||
- **Dark mode ("Ancient Manuscript")**: Deep warm brown backgrounds, parchment text, golden saffron primary
|
||||
### Color Palettes
|
||||
|
||||
Three themes per mode, all named after concepts from the Vedic tradition. Each defines a complete set of CSS variables (background, foreground, primary, card, muted, accent, border, ring, sidebar variants, charts) for cohesive styling site-wide.
|
||||
|
||||
**Light palettes**
|
||||
- **Aged Parchment** (default) — warm cream backgrounds, deep sepia text, saffron/ochre primary
|
||||
- **Tulasī Garden** — sage-green backgrounds, deep forest text, emerald primary; inspired by the sacred Tulasī plant
|
||||
- **Yamunā Mist** — soft blue backgrounds, deep teal text, cerulean primary; inspired by the sacred Yamunā river
|
||||
|
||||
**Dark palettes**
|
||||
- **Ancient Manuscript** (default) — deep warm brown backgrounds, parchment text, golden saffron primary
|
||||
- **Nīlamaṇi** — deep navy backgrounds, soft moonlight text, sky-blue primary; “blue jewel,” one of Kṛṣṇa's epithets
|
||||
- **Vraja Twilight** — deep wine backgrounds, warm cream text, rose-red primary; the crimson dusk over Vraja
|
||||
|
||||
Palettes are switched independently of theme: the user can pick their preferred light palette AND their preferred dark palette — the right one takes effect automatically when next-themes toggles between modes. Selection happens in `components/reading-settings.tsx` via a 3-up grid of swatch buttons (each swatch is a paint-chip-style square with the palette's background colour plus a small primary-coloured dot in the corner).
|
||||
|
||||
### Text Selection Toolbar
|
||||
- Global floating toolbar appears on text selection across all reading pages (except RTFs)
|
||||
@@ -757,6 +648,8 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
- Server-side `extractMatchedText()` searches paragraphs for OG image quote extraction
|
||||
- Case-insensitive matching for both DOM highlighting and server-side text extraction
|
||||
- Cross-paragraph matching via position mapping (spaces inserted at element boundaries)
|
||||
- **HTML-entity regex must be strict.** `extractMatchedText()` strips HTML entities from paragraph strings before searching. The regex matches only real entity shapes — named (`&`), decimal (`'`), and hex (`'`) — not the loose `&[^;]+;` pattern, which would pair a stray `&` (e.g. "24 & 25 Oct") with a far-away `;` (e.g. "my beloved son;") and eat everything between them, breaking OG quote extraction for any selection that spans such a stray ampersand. The browser-side `findAndHighlightText` is unaffected because it walks DOM text nodes that the browser has already decoded.
|
||||
- `buildSearchResultUrl(resultUrl, snippetHtml)` extracts the matched text from a search snippet's `<mark>` tags and appends `?hl=...` to the result URL so the destination page highlights the match — same infrastructure as share links.
|
||||
|
||||
### Dynamic OG Images
|
||||
- `/api/og` route generates 1200×630 PNG images using `next/og` (Satori)
|
||||
@@ -771,7 +664,10 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
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
|
||||
- **Mobile**: Full player shows two rows: track info row + centered transport controls
|
||||
- **Auto-minimize**: After 8 seconds of inactivity on **both mobile and desktop**, the player auto-minimizes to a floating pill. The minimized pill shows cover image with a circular progress ring and audio wave animation. Tap/click to expand.
|
||||
- **X button behavior**: Minimizes the player if audio is playing; stops playback entirely if audio is paused
|
||||
- **Clicking outside the player**: Minimizes it (does not stop playback)
|
||||
- **Z-index**: `z-40` — below text selection toolbar (z-80), drawers (z-90), modals (z-100)
|
||||
- **Hidden when selection toolbar is active** on mobile to avoid overlap
|
||||
- **Forced dark surface** (`bg-[#1c1c1e]`) for accessibility contrast regardless of theme
|
||||
@@ -799,30 +695,32 @@ The `AudioPlayerProvider` wraps the entire app in `layout.tsx`. The `FloatingPla
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin
|
||||
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org
|
||||
BFF_URL=http://localhost:8080 # Go BFF API server URL (default: http://localhost:8080)
|
||||
ALLOW_RTF=true # Enable RTF viewer (development only)
|
||||
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org # Public site URL for OG images
|
||||
```
|
||||
|
||||
> No new vars are needed for the frontend search feature — search goes through the BFF, which has `TYPESENSE_URL` and `TYPESENSE_API_KEY` configured server-side.
|
||||
|
||||
---
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Development (from knowledgebase/)
|
||||
npm run dev
|
||||
# 1. Start the Go BFF server (required — all data comes from the BFF)
|
||||
cd spsays-bff && go run cmd/api/main.go # Starts on :8080 by default
|
||||
|
||||
# Prisma
|
||||
npx prisma generate # Regenerate Prisma client after schema changes
|
||||
# 2. Development (from knowledgebase/)
|
||||
npm run dev # Next.js dev server with Turbopack
|
||||
|
||||
# Build
|
||||
# 3. Build
|
||||
npx next build
|
||||
|
||||
# Extraction & seeding scripts are in extractor/ — see extractor/scripts/
|
||||
|
||||
# BFF API server (from ../spsays-bff/)
|
||||
make run # Build + start Go API on :8080
|
||||
```
|
||||
|
||||
> **Note**: No `npx prisma generate`, `npx prisma db push`, or MongoDB connection string needed in the frontend. The BFF server must be running for any page to load data.
|
||||
|
||||
---
|
||||
|
||||
## Verse JSON Schema (BG/SB/CC/SB1962/ISO/NOI)
|
||||
@@ -946,7 +844,7 @@ Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **MongoDB BSON wire corruption**: The MongoDB server occasionally corrupts large BSON documents. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time.
|
||||
1. **BFF dependency** — The knowledgebase requires the Go BFF server to be running; all pages will fail to load data if the BFF is down.
|
||||
|
||||
2. **SB verse numbering**: 5 duplicate verse reference tags in the HTML. Fixed by using the TEXT N header as authoritative.
|
||||
|
||||
@@ -956,10 +854,12 @@ Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `
|
||||
|
||||
5. **CSS zoom for font scaling**: `zoom` property works in Chrome/Safari/Edge but may not work in older Firefox (< 126). A `transform: scale()` fallback is provided via `@supports not (zoom: 1.1)`.
|
||||
|
||||
6. **Password protection requires ACCESS_KEY at runtime**: The `PasswordGate` component checks the server at runtime via `/api/verify-access`. The env var must be available when the server is running, not just at build time.
|
||||
6. **Transcription BSON fallback** — 1 document stores content as `contentJson` string due to BSON wire protocol limits; `getTranscription()` transparently parses it
|
||||
|
||||
7. **Transcription BSON fallback** — 1 document stores content as `contentJson` string due to BSON wire protocol limits; `getTranscription()` transparently parses it
|
||||
7. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
|
||||
|
||||
8. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
|
||||
8. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
|
||||
|
||||
9. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
|
||||
9. **Search depends on Typesense** — Search depends on Typesense being available via the BFF; if Typesense is down, search will return empty results but the rest of the site is unaffected.
|
||||
|
||||
10. **Palette FOUC on first load** — Palette data attributes are applied via the `applyClasses` effect after first paint. A user with a non-default palette will see a brief flash of the default palette before their preference kicks in. Could be fixed by injecting an inline script in `layout.tsx` that reads `localStorage` before hydration (next-themes does this for the `.dark` class). Not yet implemented.
|
||||
+32
-8
@@ -4,16 +4,18 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import {
|
||||
BookOpen,
|
||||
Users,
|
||||
BrainCircuit,
|
||||
Blocks,
|
||||
Circle,
|
||||
CheckCircle2,
|
||||
CircleCheck,
|
||||
Clock
|
||||
} from "lucide-react";
|
||||
|
||||
const PHASES = [
|
||||
{
|
||||
title: "Phase I — The Vedabase",
|
||||
status: "in-progress" as const,
|
||||
status: "done" as const,
|
||||
icon: BookOpen,
|
||||
description:
|
||||
"Indexing the complete pre-1980s Vedabase into a structured, searchable database — books, letters, essays, magazines, journals, glossary, and disciple records — with a clean, accessible reading interface.",
|
||||
@@ -26,7 +28,21 @@ const PHASES = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Phase II — AI Companion",
|
||||
title: "Phase II — Devotee Features",
|
||||
status: "in-progress" as const,
|
||||
icon: Users,
|
||||
description:
|
||||
"Adding user-centric features so devotees can engage with the archive personally — sign in, save favourite quotes, build collections, share with attribution, and tag someone to a specific text.",
|
||||
highlights: [
|
||||
"Account sign-in and personal profiles",
|
||||
"Save and organise quotes into collections",
|
||||
"Share quotes with rich attribution and highlight links",
|
||||
"Bookmark verses, letters, and lectures for later reading",
|
||||
"Mention on text — tag someone to look into your selected text",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Phase III — AI Companion",
|
||||
status: "upcoming" as const,
|
||||
icon: BrainCircuit,
|
||||
description:
|
||||
@@ -38,7 +54,7 @@ const PHASES = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Phase III — Decentralized Preservation",
|
||||
title: "Phase IV — Decentralized Preservation",
|
||||
status: "upcoming" as const,
|
||||
icon: Blocks,
|
||||
description:
|
||||
@@ -134,7 +150,7 @@ export default function AboutPage() {
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">Roadmap</h2>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Our work is organized into three phases — each building on the
|
||||
Our work is organized into four phases — each building on the
|
||||
last.
|
||||
</p>
|
||||
</div>
|
||||
@@ -145,13 +161,16 @@ export default function AboutPage() {
|
||||
|
||||
{PHASES.map((phase, i) => {
|
||||
const Icon = phase.icon;
|
||||
const isDone = phase.status === "done";
|
||||
const isInProgress = phase.status === "in-progress";
|
||||
|
||||
return (
|
||||
<div key={i} className="relative flex gap-5 pb-10 last:pb-0">
|
||||
{/* Timeline node */}
|
||||
<div className="relative z-10 flex items-center justify-center size-10 shrink-0 rounded-full bg-background ring-1 ring-border">
|
||||
{isInProgress ? (
|
||||
{isDone ? (
|
||||
<Icon className="size-4 text-primary" />
|
||||
) : isInProgress ? (
|
||||
<div className="relative">
|
||||
<Icon className="size-4 text-primary" />
|
||||
<span className="absolute -top-1 -right-1 size-2.5 rounded-full bg-primary animate-pulse" />
|
||||
@@ -167,9 +186,14 @@ export default function AboutPage() {
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{phase.title}
|
||||
</h3>
|
||||
{isInProgress ? (
|
||||
{isDone ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/15 px-2.5 py-0.5 text-xs font-medium text-primary">
|
||||
<CircleCheck className="size-3" />
|
||||
Complete
|
||||
</span>
|
||||
) : isInProgress ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
|
||||
<CheckCircle2 className="size-3" />
|
||||
<Clock className="size-3" />
|
||||
In Progress
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BFF_BASE = process.env.BFF_URL || "http://localhost:8080";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const q = searchParams.get("q") || "";
|
||||
const type = searchParams.get("type") || "all";
|
||||
const page = searchParams.get("page") || "1";
|
||||
const limit = searchParams.get("limit") || "20";
|
||||
|
||||
if (!q.trim()) {
|
||||
return NextResponse.json({
|
||||
query: "",
|
||||
type,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total_pages: 0,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("/api/v1/search", BFF_BASE);
|
||||
url.searchParams.set("q", q);
|
||||
url.searchParams.set("type", type);
|
||||
url.searchParams.set("page", page);
|
||||
url.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
query: q,
|
||||
type,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
total_pages: 0,
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// Unwrap BFF envelope: { data: { ... } } → { ... }
|
||||
const payload = data.data ?? data;
|
||||
return NextResponse.json(payload);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
query: q,
|
||||
type,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
total_pages: 0,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export function BooksGrid({ books }: { books: BookInfo[] }) {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search books..."
|
||||
placeholder="Search books by name..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
||||
@@ -91,7 +91,7 @@ export function EssaysGrid({ essays }: { essays: EssayInfo[] }) {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search essays..."
|
||||
placeholder="Search essays by title..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
||||
+144
@@ -89,6 +89,78 @@
|
||||
--reading-text-align: start;
|
||||
}
|
||||
|
||||
/* ── Light palette — "Tulasī Garden" ──
|
||||
A fresh, distinctly GREEN palette — noticeably green background
|
||||
(not just a green-tinted white), with deep forest text and primary. */
|
||||
:root:not(.dark)[data-light-palette="tulasi-garden"] {
|
||||
--background: oklch(0.92 0.07 152);
|
||||
--foreground: oklch(0.22 0.09 155);
|
||||
--card: oklch(0.95 0.055 150);
|
||||
--card-foreground: oklch(0.22 0.09 155);
|
||||
--popover: oklch(0.95 0.055 150);
|
||||
--popover-foreground: oklch(0.22 0.09 155);
|
||||
--primary: oklch(0.45 0.19 150);
|
||||
--primary-foreground: oklch(0.97 0.04 150);
|
||||
--secondary: oklch(0.85 0.1 152);
|
||||
--secondary-foreground: oklch(0.22 0.09 155);
|
||||
--muted: oklch(0.87 0.09 152);
|
||||
--muted-foreground: oklch(0.42 0.11 150);
|
||||
--accent: oklch(0.87 0.09 152);
|
||||
--accent-foreground: oklch(0.22 0.09 155);
|
||||
--border: oklch(0.76 0.12 150);
|
||||
--input: oklch(0.76 0.12 150);
|
||||
--ring: oklch(0.56 0.17 150);
|
||||
--chart-1: oklch(0.45 0.19 150);
|
||||
--chart-2: oklch(0.42 0.11 150);
|
||||
--chart-3: oklch(0.56 0.17 150);
|
||||
--chart-4: oklch(0.34 0.12 145);
|
||||
--chart-5: oklch(0.22 0.09 155);
|
||||
--sidebar: oklch(0.95 0.055 150);
|
||||
--sidebar-foreground: oklch(0.22 0.09 155);
|
||||
--sidebar-primary: oklch(0.45 0.19 150);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.04 150);
|
||||
--sidebar-accent: oklch(0.87 0.09 152);
|
||||
--sidebar-accent-foreground: oklch(0.22 0.09 155);
|
||||
--sidebar-border: oklch(0.76 0.12 150);
|
||||
--sidebar-ring: oklch(0.56 0.17 150);
|
||||
}
|
||||
|
||||
/* ── Light palette — "Yamunā Mist" ──
|
||||
A distinctly BLUE palette inspired by the sacred Yamunā river —
|
||||
noticeably blue background (not just a blue-tinted white). */
|
||||
:root:not(.dark)[data-light-palette="yamuna-mist"] {
|
||||
--background: oklch(0.92 0.05 230);
|
||||
--foreground: oklch(0.24 0.09 240);
|
||||
--card: oklch(0.95 0.04 230);
|
||||
--card-foreground: oklch(0.24 0.09 240);
|
||||
--popover: oklch(0.95 0.04 230);
|
||||
--popover-foreground: oklch(0.24 0.09 240);
|
||||
--primary: oklch(0.52 0.2 240);
|
||||
--primary-foreground: oklch(0.97 0.03 230);
|
||||
--secondary: oklch(0.85 0.08 232);
|
||||
--secondary-foreground: oklch(0.24 0.09 240);
|
||||
--muted: oklch(0.87 0.07 232);
|
||||
--muted-foreground: oklch(0.44 0.11 235);
|
||||
--accent: oklch(0.87 0.07 232);
|
||||
--accent-foreground: oklch(0.24 0.09 240);
|
||||
--border: oklch(0.76 0.1 232);
|
||||
--input: oklch(0.76 0.1 232);
|
||||
--ring: oklch(0.62 0.15 235);
|
||||
--chart-1: oklch(0.52 0.2 240);
|
||||
--chart-2: oklch(0.44 0.11 235);
|
||||
--chart-3: oklch(0.62 0.15 235);
|
||||
--chart-4: oklch(0.38 0.13 240);
|
||||
--chart-5: oklch(0.24 0.09 240);
|
||||
--sidebar: oklch(0.95 0.04 230);
|
||||
--sidebar-foreground: oklch(0.24 0.09 240);
|
||||
--sidebar-primary: oklch(0.52 0.2 240);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.03 230);
|
||||
--sidebar-accent: oklch(0.87 0.07 232);
|
||||
--sidebar-accent-foreground: oklch(0.24 0.09 240);
|
||||
--sidebar-border: oklch(0.76 0.1 232);
|
||||
--sidebar-ring: oklch(0.62 0.15 235);
|
||||
}
|
||||
|
||||
/* ── Dark mode — "Ancient Manuscript" ── */
|
||||
.dark {
|
||||
--background: oklch(0.197 0.013 55.5);
|
||||
@@ -124,6 +196,78 @@
|
||||
--sidebar-ring: oklch(0.636 0.08 86.9);
|
||||
}
|
||||
|
||||
/* ── Dark palette — "Nīlamaṇi" ──
|
||||
"Blue jewel" — Kṛṣṇa's sapphire complexion. Deep navy with
|
||||
bright sky-blue accents — cool and distinctly BLUE. */
|
||||
.dark[data-dark-palette="nilamani"] {
|
||||
--background: oklch(0.16 0.05 255);
|
||||
--foreground: oklch(0.92 0.04 220);
|
||||
--card: oklch(0.21 0.06 252);
|
||||
--card-foreground: oklch(0.92 0.04 220);
|
||||
--popover: oklch(0.21 0.06 252);
|
||||
--popover-foreground: oklch(0.92 0.04 220);
|
||||
--primary: oklch(0.72 0.18 235);
|
||||
--primary-foreground: oklch(0.16 0.05 255);
|
||||
--secondary: oklch(0.28 0.06 248);
|
||||
--secondary-foreground: oklch(0.92 0.04 220);
|
||||
--muted: oklch(0.28 0.06 248);
|
||||
--muted-foreground: oklch(0.7 0.07 230);
|
||||
--accent: oklch(0.28 0.06 248);
|
||||
--accent-foreground: oklch(0.92 0.04 220);
|
||||
--border: oklch(0.7 0.12 235 / 22%);
|
||||
--input: oklch(0.7 0.12 235 / 26%);
|
||||
--ring: oklch(0.66 0.14 235);
|
||||
--chart-1: oklch(0.72 0.18 235);
|
||||
--chart-2: oklch(0.7 0.07 230);
|
||||
--chart-3: oklch(0.66 0.14 235);
|
||||
--chart-4: oklch(0.46 0.1 240);
|
||||
--chart-5: oklch(0.28 0.06 248);
|
||||
--sidebar: oklch(0.21 0.06 252);
|
||||
--sidebar-foreground: oklch(0.92 0.04 220);
|
||||
--sidebar-primary: oklch(0.72 0.18 235);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.05 255);
|
||||
--sidebar-accent: oklch(0.28 0.06 248);
|
||||
--sidebar-accent-foreground: oklch(0.92 0.04 220);
|
||||
--sidebar-border: oklch(0.7 0.12 235 / 22%);
|
||||
--sidebar-ring: oklch(0.66 0.14 235);
|
||||
}
|
||||
|
||||
/* ── Dark palette — "Vraja Twilight" ──
|
||||
The crimson dusk over Vraja — deep wine background with warm
|
||||
rose-red accents. Distinctly RED/WARM, no overlap with the others. */
|
||||
.dark[data-dark-palette="vraja-twilight"] {
|
||||
--background: oklch(0.18 0.05 15);
|
||||
--foreground: oklch(0.92 0.03 25);
|
||||
--card: oklch(0.22 0.06 12);
|
||||
--card-foreground: oklch(0.92 0.03 25);
|
||||
--popover: oklch(0.22 0.06 12);
|
||||
--popover-foreground: oklch(0.92 0.03 25);
|
||||
--primary: oklch(0.72 0.18 25);
|
||||
--primary-foreground: oklch(0.18 0.05 15);
|
||||
--secondary: oklch(0.28 0.06 15);
|
||||
--secondary-foreground: oklch(0.92 0.03 25);
|
||||
--muted: oklch(0.28 0.06 15);
|
||||
--muted-foreground: oklch(0.7 0.07 22);
|
||||
--accent: oklch(0.28 0.06 15);
|
||||
--accent-foreground: oklch(0.92 0.03 25);
|
||||
--border: oklch(0.7 0.12 25 / 22%);
|
||||
--input: oklch(0.7 0.12 25 / 26%);
|
||||
--ring: oklch(0.66 0.14 25);
|
||||
--chart-1: oklch(0.72 0.18 25);
|
||||
--chart-2: oklch(0.7 0.07 22);
|
||||
--chart-3: oklch(0.66 0.14 25);
|
||||
--chart-4: oklch(0.46 0.1 20);
|
||||
--chart-5: oklch(0.28 0.06 15);
|
||||
--sidebar: oklch(0.22 0.06 12);
|
||||
--sidebar-foreground: oklch(0.92 0.03 25);
|
||||
--sidebar-primary: oklch(0.72 0.18 25);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.05 15);
|
||||
--sidebar-accent: oklch(0.28 0.06 15);
|
||||
--sidebar-accent-foreground: oklch(0.92 0.03 25);
|
||||
--sidebar-border: oklch(0.7 0.12 25 / 22%);
|
||||
--sidebar-ring: oklch(0.66 0.14 25);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -291,8 +291,28 @@ export function GlossaryClient({
|
||||
letterCounts,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTermRaw, setActiveTerm] = useState(entries[0]?.term || "");
|
||||
|
||||
// Initial active term: prefer ?term= URL param, then fall back to first entry
|
||||
const initialTerm = useMemo(() => {
|
||||
const termParam = searchParams.get("term");
|
||||
if (termParam) {
|
||||
// Match case-insensitively and ignore diacritics
|
||||
const normalize = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
const normalizedParam = normalize(termParam);
|
||||
const match = entries.find((e) => normalize(e.term) === normalizedParam);
|
||||
if (match) return match.term;
|
||||
}
|
||||
return entries[0]?.term || "";
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [activeTermRaw, setActiveTerm] = useState(initialTerm);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// All term names
|
||||
|
||||
@@ -82,7 +82,7 @@ export function OtherDocumentsGrid({
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
placeholder="Search documents by title..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { SearchContent } from "./search-content";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return buildOgMeta({
|
||||
title: "Search",
|
||||
detail: "Search across all content",
|
||||
quote:
|
||||
"Search across Śrīla Prabhupāda's books, letters, lectures, conversations, and more.",
|
||||
});
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return <SearchContent />;
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildSearchResultUrl } from "@/lib/text-highlight";
|
||||
import {
|
||||
Search,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
Mail,
|
||||
Mic,
|
||||
BookA,
|
||||
PenLine,
|
||||
NotebookPen,
|
||||
Newspaper,
|
||||
FileStack,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SearchResult {
|
||||
collection: string;
|
||||
id: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
url: string;
|
||||
meta: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
query: string;
|
||||
type: string;
|
||||
results: SearchResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONTENT_TABS = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Books", value: "books" },
|
||||
{ label: "Letters", value: "letters" },
|
||||
{ label: "Transcriptions", value: "transcriptions" },
|
||||
{ label: "Glossary", value: "glossary" },
|
||||
{ label: "Essays", value: "essays" },
|
||||
{ label: "Journals", value: "journals" },
|
||||
{ label: "Magazines", value: "magazines" },
|
||||
{ label: "Other Documents", value: "other_documents" },
|
||||
] as const;
|
||||
|
||||
// Maps a frontend tab value to the actual BFF collection name(s).
|
||||
// "books" expands to verses + prose for the API call.
|
||||
const TAB_TO_COLLECTIONS: Record<string, string[]> = {
|
||||
books: ["verses", "prose"],
|
||||
};
|
||||
|
||||
function tabsToBffType(tabs: Set<string>): string {
|
||||
if (tabs.size === 0) return "all";
|
||||
const collections: string[] = [];
|
||||
for (const tab of tabs) {
|
||||
const expanded = TAB_TO_COLLECTIONS[tab];
|
||||
if (expanded) {
|
||||
collections.push(...expanded);
|
||||
} else {
|
||||
collections.push(tab);
|
||||
}
|
||||
}
|
||||
return collections.join(",");
|
||||
}
|
||||
|
||||
function bffTypeToTabs(typeParam: string): Set<string> {
|
||||
if (!typeParam || typeParam === "all") return new Set<string>();
|
||||
const collections = typeParam
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const tabs = new Set<string>();
|
||||
for (const col of collections) {
|
||||
// If this collection is part of an expanded tab, use the tab name
|
||||
let mapped = false;
|
||||
for (const [tab, expanded] of Object.entries(TAB_TO_COLLECTIONS)) {
|
||||
if (expanded.includes(col)) {
|
||||
tabs.add(tab);
|
||||
mapped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mapped) tabs.add(col);
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
const COLLECTION_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; icon: typeof BookOpen; color: string }
|
||||
> = {
|
||||
verses: {
|
||||
label: "Verse",
|
||||
icon: BookOpen,
|
||||
color: "bg-primary/10 text-primary",
|
||||
},
|
||||
prose: {
|
||||
label: "Prose",
|
||||
icon: BookOpen,
|
||||
color: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
},
|
||||
letters: {
|
||||
label: "Letter",
|
||||
icon: Mail,
|
||||
color: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
},
|
||||
transcriptions: {
|
||||
label: "Transcript",
|
||||
icon: Mic,
|
||||
color: "bg-purple-500/10 text-purple-700 dark:text-purple-400",
|
||||
},
|
||||
glossary: {
|
||||
label: "Glossary",
|
||||
icon: BookA,
|
||||
color: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||
},
|
||||
essays: {
|
||||
label: "Essay",
|
||||
icon: PenLine,
|
||||
color: "bg-teal-500/10 text-teal-700 dark:text-teal-400",
|
||||
},
|
||||
journals: {
|
||||
label: "Journal",
|
||||
icon: NotebookPen,
|
||||
color: "bg-orange-500/10 text-orange-700 dark:text-orange-400",
|
||||
},
|
||||
magazines: {
|
||||
label: "Magazine",
|
||||
icon: Newspaper,
|
||||
color: "bg-pink-500/10 text-pink-700 dark:text-pink-400",
|
||||
},
|
||||
other_documents: {
|
||||
label: "Document",
|
||||
icon: FileStack,
|
||||
color: "bg-muted text-muted-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debounce hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner search component (uses useSearchParams)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SearchInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Read initial state from URL
|
||||
const initialQuery = searchParams.get("q") || "";
|
||||
const initialType = searchParams.get("type") || "all";
|
||||
const initialPage = parseInt(searchParams.get("page") || "1", 10);
|
||||
|
||||
const [inputValue, setInputValue] = useState(initialQuery);
|
||||
// selectedTypes holds tab values (e.g., "books", "letters") — not raw collection names.
|
||||
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(() =>
|
||||
bffTypeToTabs(initialType),
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(initialPage);
|
||||
|
||||
// Convert selected tabs to the BFF type param ("books" → "verses,prose")
|
||||
const activeTypeParam = tabsToBffType(selectedTypes);
|
||||
// For URL: keep the tab-friendly form ("books") instead of the expanded form
|
||||
const urlTypeParam =
|
||||
selectedTypes.size === 0 ? "all" : Array.from(selectedTypes).join(",");
|
||||
const [data, setData] = useState<SearchResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const debouncedQuery = useDebouncedValue(inputValue, 300);
|
||||
const isQueryEmpty = !debouncedQuery.trim();
|
||||
|
||||
// When the query is empty, derive these as "no results" during render
|
||||
// instead of setting state inside an effect.
|
||||
const effectiveData = isQueryEmpty ? null : data;
|
||||
const hasSearched = isQueryEmpty ? false : !!debouncedQuery.trim();
|
||||
|
||||
// Sync URL when state changes
|
||||
const pushUrl = useCallback(
|
||||
(q: string, typeParam: string, page: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
if (typeParam !== "all") params.set("type", typeParam);
|
||||
if (page > 1) params.set("page", String(page));
|
||||
const qs = params.toString();
|
||||
router.replace(`/search${qs ? `?${qs}` : ""}`, { scroll: false });
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
// Reset page to 1 when query or type changes.
|
||||
// Uses the "compare previous value during render" pattern
|
||||
// recommended by React instead of a setState-in-effect.
|
||||
const [prevQuery, setPrevQuery] = useState(debouncedQuery);
|
||||
const [prevType, setPrevType] = useState(activeTypeParam);
|
||||
if (prevQuery !== debouncedQuery || prevType !== activeTypeParam) {
|
||||
setPrevQuery(debouncedQuery);
|
||||
setPrevType(activeTypeParam);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
|
||||
// Fetch search results
|
||||
useEffect(() => {
|
||||
const query = debouncedQuery.trim();
|
||||
if (!query) {
|
||||
pushUrl("", urlTypeParam, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
pushUrl(query, urlTypeParam, currentPage);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
type: activeTypeParam,
|
||||
page: String(currentPage),
|
||||
limit: "20",
|
||||
});
|
||||
|
||||
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
.then((json: SearchResponse) => {
|
||||
// Defensive: ensure results is always an array (BFF may return null)
|
||||
setData({ ...json, results: json.results ?? [] });
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
setIsLoading(false);
|
||||
setData({
|
||||
query,
|
||||
type: activeTypeParam,
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total_pages: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, pushUrl]);
|
||||
|
||||
// Auto-focus the input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleTypeChange = (type: string) => {
|
||||
if (type === "all") {
|
||||
// "All" clears all individual selections
|
||||
setSelectedTypes(new Set());
|
||||
} else {
|
||||
setSelectedTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) {
|
||||
// Deselect — if nothing left, goes back to "all"
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isTypeActive = (type: string): boolean => {
|
||||
if (type === "all") return selectedTypes.size === 0;
|
||||
return selectedTypes.has(type);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Search</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Search across all of Śrīla Prabhupāda's books, letters, lectures,
|
||||
and more
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder="Search for verses, letters, lectures, terms..."
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="h-14 pl-12 pr-4 text-lg rounded-xl"
|
||||
/>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Type Tabs */}
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{CONTENT_TABS.map((tab) => {
|
||||
const active = isTypeActive(tab.value);
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => handleTypeChange(tab.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{effectiveData &&
|
||||
active &&
|
||||
selectedTypes.size <= 1 &&
|
||||
effectiveData.total > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none",
|
||||
"bg-primary-foreground/20 text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{effectiveData.total.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Results Area */}
|
||||
<div className="mt-8">
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span>Searching…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No query */}
|
||||
{!isLoading && !hasSearched && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Search className="size-12 text-muted-foreground/40 mb-4" />
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Search across all of Śrīla Prabhupāda's books, letters,
|
||||
lectures, and more
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{!isLoading &&
|
||||
hasSearched &&
|
||||
effectiveData &&
|
||||
effectiveData.results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Search className="size-12 text-muted-foreground/40 mb-4" />
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No results found for “{debouncedQuery}”.
|
||||
<br />
|
||||
<span className="text-sm">
|
||||
Try a different search term or content type.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!isLoading && effectiveData && effectiveData.results.length > 0 && (
|
||||
<>
|
||||
{/* Results count + top pagination */}
|
||||
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{effectiveData.total.toLocaleString()}{" "}
|
||||
{effectiveData.total === 1 ? "result " : "results "} for
|
||||
“
|
||||
{effectiveData.query}”
|
||||
</p>
|
||||
{effectiveData.total_pages > 1 && (
|
||||
<PaginationControls
|
||||
page={effectiveData.page}
|
||||
totalPages={effectiveData.total_pages}
|
||||
onPageChange={handlePageChange}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result list */}
|
||||
<div className="space-y-3">
|
||||
{effectiveData.results.map((result, i) => (
|
||||
<ResultCard
|
||||
key={`${result.collection}-${result.id || i}`}
|
||||
result={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom pagination */}
|
||||
{effectiveData.total_pages > 1 && (
|
||||
<div className="mt-8">
|
||||
<PaginationControls
|
||||
page={effectiveData.page}
|
||||
totalPages={effectiveData.total_pages}
|
||||
total={effectiveData.total}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination Controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PaginationControls({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
onPageChange,
|
||||
compact = false,
|
||||
}: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total?: number;
|
||||
onPageChange: (page: number) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
{total != null && ` · ${total.toLocaleString()} results`}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result Card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ResultCard({ result }: { result: SearchResult }) {
|
||||
const config = COLLECTION_CONFIG[result.collection] || {
|
||||
label: result.collection,
|
||||
icon: FileStack,
|
||||
color: "bg-muted text-muted-foreground",
|
||||
};
|
||||
const Icon = config.icon;
|
||||
|
||||
// Append `?hl=<matched-text>` so the destination page highlights the snippet
|
||||
// text Typesense matched. Falls back to the original URL if no marks were
|
||||
// present in the snippet.
|
||||
const href = buildSearchResultUrl(result.url, result.snippet);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block group"
|
||||
onClick={() => {
|
||||
try {
|
||||
sessionStorage.setItem("spsays_from_search", "1");
|
||||
} catch {
|
||||
/* sessionStorage unavailable */
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20 cursor-pointer">
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{/* Collection badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className="text-base font-semibold text-foreground group-hover:text-primary transition-colors leading-snug">
|
||||
{result.title}
|
||||
</p>
|
||||
|
||||
{/* Snippet with highlighted matches */}
|
||||
<p
|
||||
className="text-sm text-muted-foreground leading-relaxed [&_mark]:bg-yellow-200/80 [&_mark]:text-foreground [&_mark]:rounded-sm [&_mark]:px-0.5 dark:[&_mark]:bg-yellow-500/30"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
/>
|
||||
|
||||
{/* Meta information */}
|
||||
<ResultMeta result={result} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result Meta — varies by collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ResultMeta({ result }: { result: SearchResult }) {
|
||||
const { collection, meta } = result;
|
||||
|
||||
if (!meta || Object.keys(meta).length === 0) return null;
|
||||
|
||||
let items: string[] = [];
|
||||
|
||||
switch (collection) {
|
||||
case "verses":
|
||||
if (meta.book) items.push(meta.book);
|
||||
if (meta.reference) items.push(meta.reference);
|
||||
break;
|
||||
case "letters":
|
||||
if (meta.date) items.push(meta.date);
|
||||
if (meta.location) items.push(meta.location);
|
||||
break;
|
||||
case "transcriptions":
|
||||
if (meta.type) items.push(meta.type);
|
||||
if (meta.date) items.push(meta.date);
|
||||
if (meta.place) items.push(meta.place);
|
||||
break;
|
||||
case "glossary":
|
||||
if (meta.term) items.push(meta.term);
|
||||
break;
|
||||
case "prose":
|
||||
case "essays":
|
||||
case "journals":
|
||||
case "magazines":
|
||||
case "other_documents":
|
||||
if (meta.book) items.push(meta.book);
|
||||
break;
|
||||
default:
|
||||
// Show all meta values
|
||||
items = Object.values(meta).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{items.map((item, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs font-normal">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported wrapper with Suspense boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SearchContent() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">Search</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Search across all of Śrīla Prabhupāda's books, letters,
|
||||
lectures, and more
|
||||
</p>
|
||||
</PageHeader>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ShareNotFound() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-semibold">Link not found</h1>
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
This short link may have expired or never existed.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-primary mt-6 text-sm underline underline-offset-4"
|
||||
>
|
||||
Return to Prabhupāda Says →
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { resolveShortLink } from "@/lib/share";
|
||||
import { buildShareOgMeta } from "@/lib/share-og";
|
||||
import { ogDefault } from "@/lib/og";
|
||||
import { RedirectClient } from "./redirect-client";
|
||||
|
||||
type PageProps = { params: Promise<{ code: string }> };
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const { code } = await params;
|
||||
const fullPath = await resolveShortLink(code);
|
||||
if (!fullPath) {
|
||||
return {
|
||||
title: "Link expired — Prabhupāda Says",
|
||||
robots: { index: false, follow: false },
|
||||
...ogDefault(),
|
||||
};
|
||||
}
|
||||
return buildShareOgMeta(fullPath);
|
||||
}
|
||||
|
||||
export default async function SharePage({ params }: PageProps) {
|
||||
const { code } = await params;
|
||||
const fullPath = await resolveShortLink(code);
|
||||
if (!fullPath) notFound();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Redirecting to your highlight…
|
||||
</p>
|
||||
<noscript>
|
||||
<p className="mt-4 text-sm">
|
||||
JavaScript is disabled.{" "}
|
||||
<a className="underline" href={fullPath}>
|
||||
Continue to the page
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</noscript>
|
||||
<RedirectClient to={fullPath} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Client-side redirect: we deliberately don't use `redirect()` from
|
||||
// next/navigation on the server, because that returns a 307 before the OG
|
||||
// `<head>` is ever served. With `generateMetadata` + a 200 body, social-link
|
||||
// scrapers get the full OG card while real users still get redirected.
|
||||
export function RedirectClient({ to }: { to: string }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace(to);
|
||||
}, [router, to]);
|
||||
return null;
|
||||
}
|
||||
@@ -117,16 +117,16 @@ export default async function TranscriptionPage({
|
||||
<TranscriptionNav prev={prev} next={next} />
|
||||
|
||||
{/* Audio + Participants row */}
|
||||
<div className="w-full flex flex-col md:flex-row items-start md:items-end justify-between gap-4">
|
||||
<div className="w-full flex flex-col md:flex-row items-start md:items-start justify-between gap-4">
|
||||
{/* Participants */}
|
||||
{t.participants.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex pl-2 items-center gap-2 flex-wrap text-sm text-muted-foreground">
|
||||
<div className="">
|
||||
<div className="flex flex-row pl-2 mb-2 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
<span>Participants:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
|
||||
<div className="max-w-full flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
|
||||
{t.participants.map((p, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
@@ -143,7 +143,7 @@ export default async function TranscriptionPage({
|
||||
|
||||
{/* Play Audio button */}
|
||||
{t.audioUrl && (
|
||||
<div className="w-full md:w-auto flex justify-end">
|
||||
<div className="w-full md:w-[25%] flex justify-end">
|
||||
<PlayAudioButton
|
||||
audioUrl={t.audioUrl}
|
||||
title={t.name}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Search, CornerDownLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* HeaderSearchButton — a search icon in the page header that expands
|
||||
* into a full search input on click. Typing a query and pressing Enter
|
||||
* (or clicking the search icon again) navigates to /search?q=...
|
||||
*
|
||||
* State is controlled by the parent (PageHeader) so the header can
|
||||
* hide/show adjacent elements (logo, reading settings) on mobile.
|
||||
*
|
||||
* Pressing Escape or clicking outside collapses the input.
|
||||
* Keyboard shortcut: "/" focuses the search from anywhere on the page.
|
||||
*/
|
||||
/** Map the current pathname to the search `type` filter value. */
|
||||
function searchTypeFromPath(pathname: string): string | null {
|
||||
if (pathname.startsWith("/books")) return "books";
|
||||
if (pathname.startsWith("/letters")) return "letters";
|
||||
if (pathname.startsWith("/essays")) return "essays";
|
||||
if (pathname.startsWith("/transcriptions")) return "transcriptions";
|
||||
if (pathname.startsWith("/glossary")) return "glossary";
|
||||
if (pathname.startsWith("/journals")) return "journals";
|
||||
if (pathname.startsWith("/magazines")) return "magazines";
|
||||
if (pathname.startsWith("/other-documents")) return "other_documents";
|
||||
return null; // homepage, search page, about, etc. → "all"
|
||||
}
|
||||
|
||||
export function HeaderSearchButton({
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
}) {
|
||||
const queryRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Focus input when expanded
|
||||
useEffect(() => {
|
||||
if (expanded && inputRef.current) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onExpandedChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
// Global "/" shortcut to open search
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
onExpandedChange(true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onExpandedChange]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = queryRef.current.trim();
|
||||
const params = new URLSearchParams();
|
||||
if (trimmed) params.set("q", trimmed);
|
||||
|
||||
// Pre-select the search filter matching the current section
|
||||
const type = searchTypeFromPath(pathname);
|
||||
if (type) params.set("type", type);
|
||||
|
||||
const qs = params.toString();
|
||||
router.push(`/search${qs ? `?${qs}` : ""}`);
|
||||
onExpandedChange(false);
|
||||
queryRef.current = "";
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}, [router, onExpandedChange, pathname]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onExpandedChange(false);
|
||||
queryRef.current = "";
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[handleSubmit, onExpandedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex items-center",
|
||||
expanded && "max-md:flex-1 max-md:min-w-0",
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
key="search-input"
|
||||
initial={{ width: 36, opacity: 0 }}
|
||||
animate={{ width: "100%", opacity: 1 }}
|
||||
transition={{
|
||||
width: { type: "spring", stiffness: 400, damping: 30 },
|
||||
opacity: { duration: 0.15 },
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-12 md:h-10 rounded-lg border border-border bg-background px-3 overflow-hidden",
|
||||
"max-md:w-full",
|
||||
"md:max-w-100",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue=""
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
queryRef.current = e.target.value;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-md md:text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Search
|
||||
<CornerDownLeft className="size-3 opacity-70" />
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="search-icon"
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
type="button"
|
||||
onClick={() => onExpandedChange(true)}
|
||||
aria-label="Search (press /)"
|
||||
title="Search (press /)"
|
||||
className={cn(
|
||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<Search className="size-4.5" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { HeaderSearchButton } from "@/components/header-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
sticky = false,
|
||||
@@ -10,6 +17,29 @@ export function PageHeader({
|
||||
sticky?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Read (and consume) the search-return flag once on first render.
|
||||
// The lazy initialiser runs exactly once; on the server
|
||||
// (typeof window === "undefined") it returns false.
|
||||
const [fromSearch, setFromSearch] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
if (
|
||||
sessionStorage.getItem(SEARCH_RETURN_KEY) === "1" &&
|
||||
!pathname.startsWith("/search")
|
||||
) {
|
||||
sessionStorage.removeItem(SEARCH_RETURN_KEY);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -18,10 +48,66 @@ export function PageHeader({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
<div className="shrink-0 pt-1">
|
||||
<ReadingSettings />
|
||||
{/*
|
||||
Layout strategy:
|
||||
- Mobile: actions float to the right so the title text wraps around
|
||||
them on the first line and uses the FULL container width on every
|
||||
subsequent line (which is what `pr-*` cannot achieve since padding
|
||||
applies to all lines).
|
||||
- Desktop (md+): standard flex row so title and actions sit side-by-side.
|
||||
*/}
|
||||
<div className="max-w-5xl mx-auto md:flex md:items-start md:justify-between md:gap-4">
|
||||
{/* Actions — float on mobile (rendered first so float works correctly),
|
||||
inline flex on desktop. Order doesn't matter visually on desktop
|
||||
because the parent's `justify-between` places them at the end. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5",
|
||||
// Desktop: inline at the right of the flex row
|
||||
"md:pt-1 md:order-1",
|
||||
// Mobile float (for title wrapping) only applies when collapsed.
|
||||
// When expanded the search bar fills the full header width and
|
||||
// should NOT have a left margin or be floated.
|
||||
searchExpanded
|
||||
? "max-md:w-full max-md:min-w-0"
|
||||
: "max-md:float-right max-md:ml-2 md:shrink-0",
|
||||
)}
|
||||
>
|
||||
<HeaderSearchButton
|
||||
expanded={searchExpanded}
|
||||
onExpandedChange={setSearchExpanded}
|
||||
/>
|
||||
{/* Hide reading settings on mobile when search is active */}
|
||||
<div className={cn(searchExpanded && "max-md:hidden")}>
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title / logo area — hidden on mobile when search is active */}
|
||||
<div
|
||||
className={cn(
|
||||
"md:flex-1 md:min-w-0",
|
||||
searchExpanded && "max-md:hidden",
|
||||
)}
|
||||
>
|
||||
{fromSearch ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFromSearch(false);
|
||||
router.back();
|
||||
}}
|
||||
className="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Back to search results
|
||||
</button>
|
||||
{/* Hide the page's own breadcrumb link (always the first <a>) */}
|
||||
<div className="[&>a:first-child]:hidden">{children}</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -17,18 +17,88 @@ function useMounted() {
|
||||
);
|
||||
}
|
||||
|
||||
type LightPalette = "aged-parchment" | "tulasi-garden" | "yamuna-mist";
|
||||
type DarkPalette = "ancient-manuscript" | "nilamani" | "vraja-twilight";
|
||||
|
||||
type ReadingPrefs = {
|
||||
serifFont: boolean;
|
||||
fontSize: string;
|
||||
justified: boolean;
|
||||
lightPalette: LightPalette;
|
||||
darkPalette: DarkPalette;
|
||||
};
|
||||
|
||||
const DEFAULT_PREFS: ReadingPrefs = {
|
||||
serifFont: false,
|
||||
fontSize: "base",
|
||||
justified: false,
|
||||
lightPalette: "aged-parchment",
|
||||
darkPalette: "ancient-manuscript",
|
||||
};
|
||||
|
||||
// Preview swatches use approximations of each palette's background
|
||||
// and primary colours — the real colours come from the CSS variables.
|
||||
const LIGHT_PALETTES: {
|
||||
id: LightPalette;
|
||||
name: string;
|
||||
bg: string;
|
||||
primary: string;
|
||||
}[] = [
|
||||
// Warm cream + saffron — the original
|
||||
{
|
||||
id: "aged-parchment",
|
||||
name: "Aged Parchment",
|
||||
bg: "#f5ecd7",
|
||||
primary: "#b88940",
|
||||
},
|
||||
// Sage green background + deep emerald — distinctly GREEN
|
||||
{
|
||||
id: "tulasi-garden",
|
||||
name: "Tulasī Garden",
|
||||
bg: "#c4dcc1",
|
||||
primary: "#207537",
|
||||
},
|
||||
// Soft blue background + cerulean — distinctly BLUE
|
||||
{
|
||||
id: "yamuna-mist",
|
||||
name: "Yamunā Mist",
|
||||
bg: "#c2d7ec",
|
||||
primary: "#1f6fd6",
|
||||
},
|
||||
];
|
||||
|
||||
const DARK_PALETTES: {
|
||||
id: DarkPalette;
|
||||
name: string;
|
||||
bg: string;
|
||||
primary: string;
|
||||
}[] = [
|
||||
// Warm brown + saffron — the original
|
||||
{
|
||||
id: "ancient-manuscript",
|
||||
name: "Ancient Manuscript",
|
||||
bg: "#2a2519",
|
||||
primary: "#d0a04e",
|
||||
},
|
||||
// Deep navy + sky-blue — distinctly BLUE
|
||||
{
|
||||
id: "nilamani",
|
||||
name: "Nīlamaṇi",
|
||||
bg: "#101a36",
|
||||
primary: "#4a9fff",
|
||||
},
|
||||
// Deep wine + rose-red — distinctly RED/WARM
|
||||
{
|
||||
id: "vraja-twilight",
|
||||
name: "Vraja Twilight",
|
||||
bg: "#2b1217",
|
||||
primary: "#e07252",
|
||||
},
|
||||
];
|
||||
|
||||
const LIGHT_PALETTE_IDS = LIGHT_PALETTES.map((p) => p.id);
|
||||
const DARK_PALETTE_IDS = DARK_PALETTES.map((p) => p.id);
|
||||
|
||||
const FONT_SIZES = [
|
||||
{ value: "xs", label: "XS" },
|
||||
{ value: "sm", label: "S" },
|
||||
@@ -58,6 +128,12 @@ function loadPrefs(): ReadingPrefs {
|
||||
typeof parsed.justified === "boolean"
|
||||
? parsed.justified
|
||||
: DEFAULT_PREFS.justified,
|
||||
lightPalette: LIGHT_PALETTE_IDS.includes(parsed.lightPalette)
|
||||
? parsed.lightPalette
|
||||
: DEFAULT_PREFS.lightPalette,
|
||||
darkPalette: DARK_PALETTE_IDS.includes(parsed.darkPalette)
|
||||
? parsed.darkPalette
|
||||
: DEFAULT_PREFS.darkPalette,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_PREFS;
|
||||
@@ -86,6 +162,11 @@ function applyClasses(prefs: ReadingPrefs) {
|
||||
|
||||
// Justified
|
||||
root.classList.toggle("reading-justified", prefs.justified);
|
||||
|
||||
// Palettes — always set both attributes so the appropriate one takes
|
||||
// effect when the user toggles light ↔ dark.
|
||||
root.setAttribute("data-light-palette", prefs.lightPalette);
|
||||
root.setAttribute("data-dark-palette", prefs.darkPalette);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
@@ -128,7 +209,7 @@ export function ReadingSettings() {
|
||||
const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs());
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const mounted = useMounted();
|
||||
|
||||
// Apply classes on mount (prefs already loaded via lazy initializer)
|
||||
@@ -178,6 +259,14 @@ export function ReadingSettings() {
|
||||
const fontSizeIndex = FONT_SIZES.findIndex((f) => f.value === prefs.fontSize);
|
||||
const currentFontSizeLabel = FONT_SIZES[fontSizeIndex]?.label ?? "M";
|
||||
|
||||
// Show palettes matching the currently visible mode. Falls back to light if
|
||||
// resolvedTheme hasn't been determined yet (e.g. first render).
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const visiblePalettes = isDark ? DARK_PALETTES : LIGHT_PALETTES;
|
||||
const activePaletteId = isDark ? prefs.darkPalette : prefs.lightPalette;
|
||||
const activePaletteName =
|
||||
visiblePalettes.find((p) => p.id === activePaletteId)?.name ?? "";
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
|
||||
@@ -188,16 +277,20 @@ export function ReadingSettings() {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
<button
|
||||
ref={buttonRef as React.Ref<HTMLButtonElement>}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Reading settings"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<Settings className="size-5 md:size-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
@@ -235,6 +328,54 @@ export function ReadingSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palette selector — shows light or dark palettes depending on
|
||||
the currently resolved theme. */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Palette
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{activePaletteName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{visiblePalettes.map((p) => {
|
||||
const isActive = activePaletteId === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updatePrefs(
|
||||
isDark
|
||||
? { darkPalette: p.id as DarkPalette }
|
||||
: { lightPalette: p.id as LightPalette },
|
||||
)
|
||||
}
|
||||
aria-label={`Use ${p.name} palette`}
|
||||
aria-pressed={isActive}
|
||||
title={p.name}
|
||||
className={cn(
|
||||
"group relative aspect-square rounded-md transition-all cursor-pointer overflow-hidden",
|
||||
"border-2 outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-border hover:border-foreground/40",
|
||||
)}
|
||||
style={{ backgroundColor: p.bg }}
|
||||
>
|
||||
{/* Primary-color dot in the corner */}
|
||||
<span
|
||||
className="absolute bottom-1.5 right-1.5 size-3 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: p.primary }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Reading
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCopyFeedback, type Position } from "./shared";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bookmark, FolderPlus, Link2, AtSign, Check } from "lucide-react";
|
||||
import { buildShortHighlightUrl } from "@/lib/text-highlight";
|
||||
import { Bookmark, FolderPlus, AtSign, Link2, Check } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -35,6 +34,18 @@ export function MorePopover({
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
const [generatingLink, setGeneratingLink] = useState(false);
|
||||
|
||||
async function handleCopyLink() {
|
||||
if (generatingLink) return;
|
||||
setGeneratingLink(true);
|
||||
try {
|
||||
const url = await buildShortHighlightUrl(selectedText);
|
||||
await copy(url);
|
||||
} finally {
|
||||
setGeneratingLink(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return;
|
||||
@@ -71,14 +82,14 @@ export function MorePopover({
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
label: copied ? "Link Copied!" : "Copy Link",
|
||||
description: "Copy a link to this page",
|
||||
icon: copied ? (
|
||||
<Check className="size-5" />
|
||||
) : (
|
||||
<Link2 className="size-5" />
|
||||
),
|
||||
onClick: () => copy(buildHighlightUrl(selectedText)),
|
||||
label: copied
|
||||
? "Link Copied!"
|
||||
: generatingLink
|
||||
? "Generating link…"
|
||||
: "Copy Link",
|
||||
description: "Copy a shareable link to this quote",
|
||||
icon: copied ? <Check className="size-5" /> : <Link2 className="size-5" />,
|
||||
onClick: handleCopyLink,
|
||||
},
|
||||
{
|
||||
key: "mention",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCopyFeedback } from "./shared";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { buildShortHighlightUrl } from "@/lib/text-highlight";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, MessageSquare, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
@@ -24,6 +25,18 @@ export function ShareModal({
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
async function handleCopyShareLink() {
|
||||
if (generating) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const url = await buildShortHighlightUrl(selectedText);
|
||||
await copy(url);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const shareOptions = [
|
||||
{
|
||||
@@ -41,12 +54,17 @@ export function ShareModal({
|
||||
label: "Share Link",
|
||||
icon: <ExternalLink className="size-4" />,
|
||||
description: "Copy a shareable link",
|
||||
onClick: () => {
|
||||
copy(buildHighlightUrl(selectedText));
|
||||
},
|
||||
onClick: handleCopyShareLink,
|
||||
},
|
||||
];
|
||||
|
||||
function linkLabel(optKey: string, optLabel: string) {
|
||||
if (optKey !== "link") return optLabel;
|
||||
if (copied) return "Link Copied!";
|
||||
if (generating) return "Generating link…";
|
||||
return optLabel;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -72,7 +90,7 @@ export function ShareModal({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
|
||||
{linkLabel(opt.key, opt.label)}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{opt.description}</p>
|
||||
</div>
|
||||
@@ -130,7 +148,7 @@ export function ShareModal({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
|
||||
{linkLabel(opt.key, opt.label)}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{opt.description}</p>
|
||||
</div>
|
||||
|
||||
+13
-1
@@ -397,5 +397,17 @@ export async function getProseChapter(
|
||||
* Get all glossary entries, ordered alphabetically by term.
|
||||
*/
|
||||
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
|
||||
return bffList<GlossaryRecord>("/glossary", { tags: ["glossary"] });
|
||||
const raw = await bffList<{
|
||||
id: string;
|
||||
term: string;
|
||||
definition: string;
|
||||
definition_html: string;
|
||||
}>("/glossary", { tags: ["glossary"] });
|
||||
|
||||
return raw.map((entry) => ({
|
||||
id: entry.id,
|
||||
term: entry.term,
|
||||
definition: entry.definition,
|
||||
definitionHtml: entry.definition_html ?? entry.definition,
|
||||
}));
|
||||
}
|
||||
|
||||
+38
@@ -121,3 +121,41 @@ export async function bffList<T>(
|
||||
const result = await bff<T[]>(path, options);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON to the BFF API. Used for mutations like the URL shortener; does
|
||||
* not participate in Next.js caching (each POST is a unique side-effect).
|
||||
*
|
||||
* Same envelope-unwrapping behavior as bff(): returns the inner `data`
|
||||
* value, throws BffError on non-2xx.
|
||||
*/
|
||||
export async function bffPost<T>(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const url = apiUrl(path);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new BffError(
|
||||
res.status,
|
||||
`BFF ${res.status} POST ${path}: ${text.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (json && typeof json === "object" && "data" in json) {
|
||||
return json.data as T;
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
+31
-1
@@ -287,8 +287,38 @@ export async function getSidebarItems(code: string): Promise<SidebarItem[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Hierarchical books (SVA, SSR, DS) — chapters have nested subChapters
|
||||
const hasHierarchical =
|
||||
meta.chapters &&
|
||||
meta.chapters.length > 0 &&
|
||||
meta.chapters.some((ch) => ch.subChapters && ch.subChapters.length > 0);
|
||||
|
||||
if (hasHierarchical && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
// Parent chapter heading (not navigable on its own)
|
||||
items.push({
|
||||
slug: `parent-${ch.number}`,
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "parent",
|
||||
chapter: ch.number,
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
// Sub-chapters
|
||||
if (ch.subChapters) {
|
||||
for (const sub of ch.subChapters) {
|
||||
items.push({
|
||||
slug: sub.slug,
|
||||
label: sub.title,
|
||||
type: "sub-chapter",
|
||||
chapter: ch.number,
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chapters — if parts exist, group chapters under part headers
|
||||
if (
|
||||
else if (
|
||||
meta.parts &&
|
||||
meta.parts.length > 0 &&
|
||||
meta.chapters &&
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* OG metadata for the /share/[code] page.
|
||||
*
|
||||
* Given a resolved long path (`/books/bg/18/66?hl=…`), this dispatches to the
|
||||
* correct page-specific OG builder so social-link previews on shortened URLs
|
||||
* look identical to previews on the canonical destination URL.
|
||||
*
|
||||
* The destination page's own `generateMetadata` is not invoked from `/share`,
|
||||
* so we replicate the equivalent fetch + `extractQuoteFromHl` + `ogFor*`
|
||||
* pipeline here. On any failure we fall back to a site-level default so the
|
||||
* link is still scrape-safe.
|
||||
*/
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
buildOgMeta,
|
||||
extractQuoteFromHl,
|
||||
ogDefault,
|
||||
ogForBook,
|
||||
ogForChapter,
|
||||
ogForEssay,
|
||||
ogForJournal,
|
||||
ogForLetter,
|
||||
ogForMagazine,
|
||||
ogForOtherDocument,
|
||||
ogForTranscription,
|
||||
ogForVerse,
|
||||
} from "@/lib/og";
|
||||
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
|
||||
import { bff } from "@/lib/bff";
|
||||
import { getSingleVerse, getProseChapter } from "@/lib/api";
|
||||
import { getLetter } from "@/lib/letters";
|
||||
import { getTranscription } from "@/lib/transcriptions";
|
||||
import {
|
||||
getJournalChapterBySlug,
|
||||
slugToBookCode as journalSlugToBookCode,
|
||||
} from "@/lib/journals";
|
||||
import {
|
||||
getMagazineIssue,
|
||||
slugToBookCode as magazineSlugToBookCode,
|
||||
} from "@/lib/magazines";
|
||||
import {
|
||||
getOtherDocumentChapter,
|
||||
slugToBookCode as otherDocSlugToBookCode,
|
||||
} from "@/lib/other-documents";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Verse-bearing books with a `/{chapter}/{verse}` leaf (no division segment).
|
||||
const VERSE_BOOKS_NO_DIVISION = new Set([
|
||||
"bg",
|
||||
"sb1962",
|
||||
"bs",
|
||||
"mm",
|
||||
"nbs",
|
||||
]);
|
||||
|
||||
// Books whose verse routes have an extra division segment (canto for SB, līlā for CC).
|
||||
const VERSE_BOOKS_WITH_DIVISION = new Set(["sb", "cc"]);
|
||||
|
||||
// Books where the leaf is a single segment (mantra / text slug).
|
||||
const VERSE_BOOKS_SINGLE_SEGMENT = new Set(["iso", "noi"]);
|
||||
|
||||
function splitPath(pathname: string): string[] {
|
||||
return pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((s) => {
|
||||
try {
|
||||
return decodeURIComponent(s);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build OG metadata for a /share/[code] page given the resolved long path.
|
||||
* Never throws — any error falls back to the site default.
|
||||
*/
|
||||
export async function buildShareOgMeta(fullPath: string): Promise<Metadata> {
|
||||
try {
|
||||
const u = new URL("http://x" + fullPath);
|
||||
const parts = splitPath(u.pathname);
|
||||
const sp = u.searchParams;
|
||||
|
||||
if (parts.length === 0) return ogDefault();
|
||||
|
||||
switch (parts[0]) {
|
||||
case "books":
|
||||
return await ogForBookPath(parts, sp);
|
||||
case "letters":
|
||||
return parts[1] ? await ogForLetterPath(parts[1], sp) : ogDefault();
|
||||
case "transcriptions":
|
||||
return parts[1]
|
||||
? await ogForTranscriptionPath(parts[1], sp)
|
||||
: ogDefault();
|
||||
case "essays":
|
||||
return parts[1] ? await ogForEssayPath(parts[1], sp) : ogDefault();
|
||||
case "journals":
|
||||
return parts.length >= 3
|
||||
? await ogForJournalPath(parts[1], parts[2], sp)
|
||||
: ogDefault();
|
||||
case "magazines":
|
||||
return parts.length >= 3
|
||||
? await ogForMagazinePath(parts[1], parts[2], sp)
|
||||
: ogDefault();
|
||||
case "other-documents":
|
||||
return parts.length >= 3
|
||||
? await ogForOtherDocPath(parts[1], parts[2], sp)
|
||||
: ogDefault();
|
||||
default:
|
||||
return ogWithFallbackQuote("Prabhupāda Says", sp);
|
||||
}
|
||||
} catch {
|
||||
return ogDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-type dispatchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ogForBookPath(
|
||||
parts: string[],
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
if (parts.length < 2) return ogDefault();
|
||||
const code = parts[1];
|
||||
|
||||
// Verse with division (SB canto, CC līlā): /books/sb/{canto}/{ch}/{v}
|
||||
if (VERSE_BOOKS_WITH_DIVISION.has(code) && parts.length >= 5) {
|
||||
return await ogForVersePath(code, parts[2], parts[3], parts[4], sp);
|
||||
}
|
||||
|
||||
// Verse without division: /books/{code}/{ch}/{v}
|
||||
if (VERSE_BOOKS_NO_DIVISION.has(code) && parts.length >= 4) {
|
||||
return await ogForVersePath(code, null, parts[2], parts[3], sp);
|
||||
}
|
||||
|
||||
// ISO / NOI: /books/{code}/{slug-or-mantra}
|
||||
if (VERSE_BOOKS_SINGLE_SEGMENT.has(code) && parts.length >= 3) {
|
||||
// Treat the leaf as a "verse"-like slug. extractQuoteFromHl falls back to
|
||||
// prefix-suffix if we can't fetch paragraphs cheaply.
|
||||
return ogWithFallbackQuote(BOOK_NAMES[code] || code.toUpperCase(), sp);
|
||||
}
|
||||
|
||||
// KB: /books/kb/{volume}/{slug}
|
||||
if (code === "kb" && parts.length >= 4) {
|
||||
const volume = Number.parseInt(parts[2], 10);
|
||||
if (Number.isFinite(volume)) {
|
||||
const chapter = await getProseChapter("kb", volume, parts[3]);
|
||||
const paragraphs = chapter?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: BOOK_NAMES.kb,
|
||||
detail: chapter?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForChapter("kb", chapter?.title ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// Generic prose book: /books/{code}/{slug}
|
||||
if (parts.length >= 3) {
|
||||
const chapter = await getProseChapter(code, 0, parts[2]);
|
||||
const paragraphs = chapter?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: BOOK_NAMES[code] || code.toUpperCase(),
|
||||
detail: chapter?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForChapter(code, chapter?.title ?? "");
|
||||
}
|
||||
|
||||
return ogForBook(code);
|
||||
}
|
||||
|
||||
async function ogForVersePath(
|
||||
code: string,
|
||||
division: string | null,
|
||||
chapter: string,
|
||||
verse: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const chapterNum = Number.parseInt(chapter, 10);
|
||||
const verseNum = Number.parseInt(verse, 10);
|
||||
if (!Number.isFinite(chapterNum) || !Number.isFinite(verseNum)) {
|
||||
return ogForBook(code);
|
||||
}
|
||||
|
||||
const v = await getSingleVerse(code, division, chapterNum, verseNum);
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
|
||||
if (quote) {
|
||||
const title = BOOK_NAMES[code] || code.toUpperCase();
|
||||
let detail: string;
|
||||
if (code === "sb" && division) {
|
||||
detail = `Canto ${division}, Chapter ${chapter}, Verse ${verse}`;
|
||||
} else if (code === "cc" && division) {
|
||||
const lila = CC_LILAS[division] || division;
|
||||
detail = `${lila}, Chapter ${chapter}, Verse ${verse}`;
|
||||
} else {
|
||||
detail = `Chapter ${chapter}, Verse ${verse}`;
|
||||
}
|
||||
return buildOgMeta({ title, detail, quote });
|
||||
}
|
||||
|
||||
return ogForVerse(code, {
|
||||
division: division ?? undefined,
|
||||
chapter: chapterNum,
|
||||
verse: verseNum,
|
||||
});
|
||||
}
|
||||
|
||||
async function ogForLetterPath(
|
||||
slug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const letter = await getLetter(slug);
|
||||
const paragraphs = letter?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
const recipient = letter?.recipientDisplay ?? "(Unknown)";
|
||||
const meta =
|
||||
letter?.date && letter?.location
|
||||
? `${letter.date} • ${letter.location}`
|
||||
: letter?.date || letter?.location || undefined;
|
||||
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: `Letter to ${recipient}`,
|
||||
detail: meta,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForLetter(recipient, meta);
|
||||
}
|
||||
|
||||
async function ogForTranscriptionPath(
|
||||
slug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const t = await getTranscription(slug);
|
||||
const paragraphs = t?.content
|
||||
?.filter((b) => b.format === "paragraph" || b.format === "quote")
|
||||
.map((b) => b.text);
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
const name = t?.name ?? "Transcription";
|
||||
const meta =
|
||||
t?.date && t?.place
|
||||
? `${t.date} • ${t.place}`
|
||||
: t?.date || t?.place || undefined;
|
||||
|
||||
if (quote) {
|
||||
return buildOgMeta({ title: name, detail: meta, quote });
|
||||
}
|
||||
return ogForTranscription(name, meta);
|
||||
}
|
||||
|
||||
type EssayDoc = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number | null;
|
||||
group: string | null;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
async function ogForEssayPath(
|
||||
slug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const doc = await bff<EssayDoc>(`/essays/${slug}`);
|
||||
const paragraphs = doc?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: doc?.title ?? "Essay",
|
||||
detail: "Essay by Śrīla Prabhupāda",
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForEssay(doc?.title ?? "Essay");
|
||||
}
|
||||
|
||||
async function ogForJournalPath(
|
||||
bookSlug: string,
|
||||
chapterSlug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const bookCode = journalSlugToBookCode(bookSlug);
|
||||
const ch = await getJournalChapterBySlug(bookCode, chapterSlug);
|
||||
const paragraphs = ch?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
// The fetched chapter type doesn't carry the parent journal title; use the
|
||||
// shared BOOK_NAMES map as the canonical display name.
|
||||
const journalTitle = BOOK_NAMES[bookCode] ?? bookSlug;
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: journalTitle,
|
||||
detail: ch?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForJournal(journalTitle, ch?.title);
|
||||
}
|
||||
|
||||
async function ogForMagazinePath(
|
||||
bookSlug: string,
|
||||
issueSlug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const bookCode = magazineSlugToBookCode(bookSlug);
|
||||
const issue = await getMagazineIssue(bookCode, issueSlug);
|
||||
const paragraphs = issue?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
const magazineTitle = BOOK_NAMES[bookCode] ?? bookSlug;
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: magazineTitle,
|
||||
detail: issue?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForMagazine(magazineTitle, issue?.title);
|
||||
}
|
||||
|
||||
async function ogForOtherDocPath(
|
||||
bookSlug: string,
|
||||
chapterPart: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const bookCode = otherDocSlugToBookCode(bookSlug);
|
||||
const chapterNum = Number.parseInt(chapterPart, 10);
|
||||
const docTitle = BOOK_NAMES[bookCode] ?? bookSlug;
|
||||
if (!Number.isFinite(chapterNum)) {
|
||||
return ogForOtherDocument(docTitle);
|
||||
}
|
||||
const ch = await getOtherDocumentChapter(bookCode, chapterNum);
|
||||
const paragraphs = ch?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: docTitle,
|
||||
detail: ch?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForOtherDocument(docTitle, ch?.title);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ogWithFallbackQuote(
|
||||
title: string,
|
||||
sp: URLSearchParams,
|
||||
): Metadata {
|
||||
// No paragraphs available — fall back to prefix … suffix reconstruction.
|
||||
const quote = extractQuoteFromHl(sp);
|
||||
if (quote) {
|
||||
return buildOgMeta({ title, quote });
|
||||
}
|
||||
return ogDefault();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Short link client — talks to the BFF /share endpoints.
|
||||
*
|
||||
* Used by the text-selection toolbar (`buildShortHighlightUrl`) and the
|
||||
* `/share/[code]` page (`resolveShortLink`).
|
||||
*/
|
||||
|
||||
import { bff, bffPost } from "@/lib/bff";
|
||||
|
||||
// Module-scoped cache: a single user shouldn't generate a fresh code every
|
||||
// time they re-click Share on the same selection. Keyed by the relative path
|
||||
// (including ?hl=…), this returns the same promise for repeat calls.
|
||||
const createCache = new Map<string, Promise<string | null>>();
|
||||
|
||||
type CreateResponse = { code: string };
|
||||
type ResolveResponse = { url: string };
|
||||
|
||||
/**
|
||||
* Create a short code for a same-site relative path (`/books/bg/18/66?hl=…`).
|
||||
* Returns `null` on any failure — caller should fall back to the long URL.
|
||||
*/
|
||||
export function createShortLink(longPath: string): Promise<string | null> {
|
||||
const cached = createCache.get(longPath);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = bffPost<CreateResponse>("/share", { url: longPath })
|
||||
.then((r) => r.code)
|
||||
.catch(() => null);
|
||||
|
||||
createCache.set(longPath, promise);
|
||||
// If the request fails, evict so the next click can retry instead of
|
||||
// returning the cached null forever.
|
||||
promise.then((code) => {
|
||||
if (code === null) createCache.delete(longPath);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a short code to its full long path. Returns `null` when the code is
|
||||
* not found, expired, disabled, or the BFF is unreachable.
|
||||
*/
|
||||
export async function resolveShortLink(code: string): Promise<string | null> {
|
||||
try {
|
||||
const r = await bff<ResolveResponse>(`/share/${encodeURIComponent(code)}`, {
|
||||
revalidate: 0,
|
||||
});
|
||||
return r?.url ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+133
-2
@@ -78,9 +78,14 @@ export function extractMatchedText(
|
||||
fragment: { prefix: string; suffix: string; length: number },
|
||||
paragraphs: string[],
|
||||
): string | null {
|
||||
// Strip HTML tags to get plain text
|
||||
// Strip HTML tags and entities to get plain text. The entity regex must be
|
||||
// strict — `&[^;]+;` would greedily pair a stray `&` (e.g. "24 & 25 Oct")
|
||||
// with a far-away `;` (e.g. "my beloved son;"), eating everything between
|
||||
// them. Only match real entities: named (&), decimal ('), hex (').
|
||||
const plainParagraphs = paragraphs.map((p) =>
|
||||
p.replace(/<[^>]*>/g, "").replace(/&[^;]+;/g, " "),
|
||||
p
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);/g, " "),
|
||||
);
|
||||
|
||||
// Build a single string with paragraph breaks
|
||||
@@ -135,6 +140,101 @@ export function extractMatchedText(
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most relevant snippet of text from a search result snippet HTML
|
||||
* (which contains `<mark>...</mark>` tags around the parts Typesense matched).
|
||||
*
|
||||
* Returns the continuous span from the first `<mark>` to the last `<mark>`,
|
||||
* with all HTML tags stripped — i.e. the actual text the user searched for as
|
||||
* it appears in the source document. Returns `null` if there are no marks or
|
||||
* the result would be empty.
|
||||
*
|
||||
* Used to build a `?hl=` URL when navigating from a search result, so the
|
||||
* destination page highlights the matched phrase.
|
||||
*
|
||||
* Examples:
|
||||
* "...just <mark>surrender unto</mark> Me..." → "surrender unto"
|
||||
* "<mark>letter</mark> to <mark>Tusta</mark> <mark>Krsna</mark>" → "letter to Tusta Krsna"
|
||||
*/
|
||||
export function extractMatchedTextFromSnippet(
|
||||
snippetHtml: string,
|
||||
): string | null {
|
||||
if (!snippetHtml) return null;
|
||||
|
||||
const matches = Array.from(
|
||||
snippetHtml.matchAll(/<mark[^>]*>([\s\S]*?)<\/mark>/gi),
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const first = matches[0];
|
||||
const last = matches[matches.length - 1];
|
||||
|
||||
const firstStart = first.index ?? 0;
|
||||
const lastEnd = (last.index ?? 0) + last[0].length;
|
||||
|
||||
const span = snippetHtml.slice(firstStart, lastEnd);
|
||||
const plain = span
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&[a-z]+;/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!plain) return null;
|
||||
|
||||
// If the span between first and last mark is unreasonably long
|
||||
// (e.g. snippet has marks at far ends of a long passage), fall back to
|
||||
// just the first marked term — that's still a real match in the doc.
|
||||
if (plain.length > 200) {
|
||||
const firstPlain = first[1]
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/&[a-z]+;/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return firstPlain || null;
|
||||
}
|
||||
|
||||
return plain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a result URL with `?hl=` appended so the destination page highlights
|
||||
* the matched phrase. Preserves any other query params already on the URL.
|
||||
*
|
||||
* Returns the original URL unchanged if no usable highlight text could be
|
||||
* extracted from the snippet.
|
||||
*/
|
||||
export function buildSearchResultUrl(
|
||||
resultUrl: string,
|
||||
snippetHtml: string,
|
||||
): string {
|
||||
const matched = extractMatchedTextFromSnippet(snippetHtml);
|
||||
if (!matched) return resultUrl;
|
||||
|
||||
const fragment = encodeTextFragment(matched);
|
||||
if (!fragment) return resultUrl;
|
||||
|
||||
// resultUrl is a relative path like "/books/bg/18/66"; we just need to
|
||||
// splice in the `hl` param without disturbing any existing query string.
|
||||
const [path, queryAndHash = ""] = resultUrl.split(/(?=[?#])/, 2);
|
||||
const hashIdx = queryAndHash.indexOf("#");
|
||||
const existingQuery =
|
||||
hashIdx >= 0 ? queryAndHash.slice(0, hashIdx) : queryAndHash;
|
||||
const hash = hashIdx >= 0 ? queryAndHash.slice(hashIdx) : "";
|
||||
|
||||
const params = new URLSearchParams(
|
||||
existingQuery.startsWith("?") ? existingQuery.slice(1) : existingQuery,
|
||||
);
|
||||
params.set("hl", fragment);
|
||||
|
||||
return `${path}?${params.toString()}${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL with highlight fragment for the selected text.
|
||||
*/
|
||||
@@ -149,6 +249,37 @@ export function buildHighlightUrl(selectedText: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a shortened, shareable URL for the current selection. POSTs the
|
||||
* `?hl=…` URL to the BFF, which stores the mapping in Postgres (Redis-queued)
|
||||
* and returns a short code. The destination `/share/<code>` page resolves
|
||||
* back to the same `?hl=…` URL the existing TextHighlighter already knows
|
||||
* how to process — no change to highlighting behavior.
|
||||
*
|
||||
* Falls back to the long URL on any failure so sharing never silently breaks.
|
||||
*
|
||||
* `buildHighlightUrl` (sync) is intentionally left in place so other callers
|
||||
* (e.g. search-result navigation) keep working unchanged.
|
||||
*/
|
||||
export async function buildShortHighlightUrl(
|
||||
selectedText: string,
|
||||
): Promise<string> {
|
||||
const longUrl = buildHighlightUrl(selectedText);
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(longUrl);
|
||||
} catch {
|
||||
return longUrl;
|
||||
}
|
||||
const relativePath = u.pathname + u.search + u.hash;
|
||||
|
||||
const { createShortLink } = await import("@/lib/share");
|
||||
const code = await createShortLink(relativePath);
|
||||
if (!code) return longUrl;
|
||||
|
||||
return new URL(`/share/${code}`, u.origin).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and highlight text in the DOM that matches a decoded text fragment.
|
||||
* Returns true if found and highlighted.
|
||||
|
||||
Reference in New Issue
Block a user