Author SHA1 Message Date
gosangam 684ea2ae97 feat: social graph features 2026-06-04 19:30:50 +05:30
gosangam 0f433c5708 fix: audio player 2026-06-03 23:01:33 +05:30
gosangam 48dd450a15 fix: post reshare and audio player enhancements 2026-06-03 14:41:34 +05:30
gosangam ddc6d3c7bf feat: avatar opener 2026-06-03 00:49:58 +05:30
gosangam fd333159f6 fix: font size revert 2026-06-02 23:51:42 +05:30
gosangam 0bcda5a791 fix: ui fixes minor 2026-06-02 23:14:34 +05:30
gosangam c89cc8b779 fix: tailwind fix 2026-06-02 22:57:22 +05:30
gosangam ec67ddf852 feat: social auth account linking 2026-06-02 22:55:09 +05:30
gosangam 086f1dd8aa fix: type fix 2026-06-02 19:38:23 +05:30
gosangam b61c3df473 feat: add google auth 2026-06-02 18:44:43 +05:30
gosangam cb70a84dd5 feat: add password reset email 2026-06-02 18:37:38 +05:30
gosangam dc01d09f09 login with facebook added 2026-06-02 15:21:47 +05:30
gosangam 84d86c45b5 post cards overlay enhancements 2026-06-02 15:13:02 +05:30
gosangam 4d45e20cb1 post cards overlay enhancements 2026-06-02 15:02:17 +05:30
gosangam e72679d1b6 oauth hidden 2026-06-02 12:20:50 +05:30
gosangam 91d5d272d2 feat: add policy pages 2026-06-02 01:57:08 +05:30
gosangam 0352cb2003 search target fix 2026-06-02 00:42:26 +05:30
gosangam 8a8ce24479 fix: reshared posts should be shown in feed 2026-05-31 14:54:48 +05:30
gosangam 7d76afe102 collection drawer ui fix 2026-05-31 11:41:18 +05:30
gosangam 8c1f3a1b7b ui fixes in bottom nav 2026-05-31 11:23:55 +05:30
gosangam 3624c19d2d search page ui fixes 2026-05-31 11:19:35 +05:30
gosangam 5988d02373 search page ui fixes 2026-05-31 11:16:50 +05:30
gosangam 17fb83087f search page ui fixes 2026-05-31 11:09:43 +05:30
gosangam 489adb0eeb feat: send icon fix 2026-05-31 10:53:21 +05:30
gosangam d8de0a18e5 feat: add user search 2026-05-31 10:43:30 +05:30
gosangam c20d6bffe8 feat: share sheet update 2026-05-31 10:25:39 +05:30
gosangam 01bcc057fa feat: share sheet update 2026-05-31 10:24:32 +05:30
gosangam 8bd82a0e8a feat: image compress before upload 2026-05-31 09:56:53 +05:30
gosangam 5506afa758 feat: share post to other apps 2026-05-31 08:44:29 +05:30
gosangam 88f1b08675 feat: share post to other apps 2026-05-31 08:20:06 +05:30
gosangam 336f478f8b feat: share post to other apps 2026-05-31 08:04:46 +05:30
gosangam 03b8f85e06 feat: add reading settings sync 2026-05-31 07:07:50 +05:30
gosangam 0ff8005df2 ui fixes 2026-05-31 06:50:05 +05:30
gosangam 0ae3334d98 type fixes 2026-05-31 00:35:00 +05:30
gosangam ec20eb996b ui enhancements 2026-05-31 00:26:05 +05:30
gosangam dcdd2383c6 feat: add user centric features 2026-05-30 23:44:08 +05:30
gosangam 7bd2952caa tsc fix 2026-05-30 02:46:41 +05:30
gosangam 6b6ce0813e feat: add auth 2026-05-30 02:34:22 +05:30
gosangam 3767a3a9b6 double quotes inclusion 2026-05-30 00:53:50 +05:30
gosangam a22fcf360d search highlight quote fix 2026-05-24 18:07:52 +05:30
gosangam 804cf1ad9a fix: about content 2026-05-17 17:03:52 +05:30
gosangam 30d08c665d feat: add proxy to url shortner 2026-05-11 17:28:04 +05:30
gosangam cc94296b46 fix: copy behaviour 2026-05-11 16:58:15 +05:30
gosangam 8cd1d2e724 feat: add url shortner 2026-05-11 16:14:08 +05:30
gosangam 179b7d8862 chore: update project context 2026-05-11 14:53:03 +05:30
gosangam 7294d9e15b og fix 2026-05-11 12:47:39 +05:30
gosangam e3df40559b fix: search bar now takes full width 2026-05-11 10:22:10 +05:30
gosangam 9a3056fbd8 theme update 2026-05-11 01:58:02 +05:30
gosangam db8deb232b header 2026-05-11 01:36:49 +05:30
gosangam 730efb1bb3 feat: add search result highlight 2026-05-11 01:01:46 +05:30
gosangam 0106d8cee2 feat: fix string in search results 2026-05-11 00:15:48 +05:30
gosangam d917b3ea7d enhance search input 2026-05-10 22:17:38 +05:30
gosangam 7c07b33e4b enhance search input 2026-05-10 22:10:12 +05:30
gosangam 96a83fe1cb fix: search component and breadcrumbs fix 2026-05-10 21:56:43 +05:30
gosangam befcc44980 fix: hide logo on search active 2026-05-10 21:12:13 +05:30
gosangam e45d5eb02c chore: update project context 2026-05-10 20:48:32 +05:30
gosangam 09fbcf93ed feat: add search 2026-05-10 20:41:58 +05:30
gosangam 4cbb146bc1 project context update 2026-05-10 01:12:10 +05:30
gosangam bf1ff47d3e text select more options update 2026-05-10 01:02:15 +05:30
gosangam 9c72ce814d fix: audio minimize issue fixed 2026-05-10 00:56:22 +05:30
gosangam 32da38b24f feat: implement bff layer 2026-05-10 00:35:14 +05:30
gosangam 5951eb5e18 chore: update project context 2026-05-09 14:59:32 +05:30
gosangam ab1517f32f fix: transcripts source and og image 2026-05-09 12:38:35 +05:30
gosangam 326ba090ca feat: add framer motion for audio player, handle og images for transcripts 2026-05-09 11:59:48 +05:30
292 changed files with 23744 additions and 24534 deletions
+257 -212
View File
@@ -15,10 +15,10 @@ Content is sourced from the pre-1980s Vedabase, extracted and indexed into Mongo
- **shadcn/ui** (base-nova style, lucide icons) — components: Button, Card, Badge, Tooltip, Input, Drawer
- **next-themes** for dark/light/system mode
- **vaul** for mobile drawer navigation
- **Prisma v6** with MongoDB (provider `prisma-client-js`)
- **MongoDB** native driver (`mongodb` package) for seeding (Prisma requires replica set for writes)
- **Go BFF API** — all data fetched via `lib/bff.ts` HTTP client calling the Go BFF server (no direct database access)
- **framer-motion** for audio player animations (spring enter/exit, minimize/expand transitions, audio wave bars)
- **Typesense** (via BFF) — typo-tolerant full-text search across all content
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
- **Database**: MongoDB at `DATABASE_URL` env variable with `?authSource=admin`
---
@@ -64,10 +64,19 @@ knowledgebase/
│ ├── disciples/ → 4,747 initiated disciples directory
│ │ ├── page.tsx → Disciples browser (letter nav, suffix filter, search, sort, pagination)
│ │ └── disciples-browser.tsx → Client component
│ ├── transcriptions/page.tsx → Coming Soon page
│ ├── transcriptions/ → 3,787 transcriptions (lectures, conversations, philosophy, dictations)
│ │ ├── page.tsx → Browse by year / place / type (3 views)
│ │ ├── year/[year]/page.tsx → Year listing, grouped by month
│ │ ├── place/[place]/page.tsx → Place listing
│ │ ├── type/[type]/page.tsx → Type listing (lecture/conversation), year sidebar
│ │ └── [slug]/page.tsx → Individual transcription reading page
│ ├── search/ → Search page (server + client components)
│ │ ├── page.tsx → Server component (URL-driven state)
│ │ └── search-content.tsx → Client component (input, filters, results)
│ └── api/
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
│ ├── books/ → Book/verse API routes
│ ├── search/route.ts → BFF search proxy (Typesense via /api/v1/search)
│ └── glossary/route.ts → Glossary API
├── components/
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer)
@@ -82,26 +91,31 @@ knowledgebase/
│ ├── page-header.tsx → Shared header with ReadingSettings (max-w-5xl)
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
│ ├── header-search.tsx → Header search button with framer-motion expand animation
│ ├── site-footer.tsx → Global footer (About + Home links)
│ ├── audio-player/ → Site-wide floating audio player
│ │ ├── audio-context.tsx → React context + provider, manages <audio> element
│ │ ├── floating-player.tsx → Floating player UI (desktop bar / mobile pill)
│ │ ├── audio-wave.tsx → Reusable animated audio wave bars
│ │ └── index.ts → Barrel exports
│ ├── logo.tsx → Theme-aware logo component
│ └── theme-provider.tsx → next-themes wrapper
├── lib/
│ ├── db.ts → Prisma client singleton
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary)
│ ├── bff.ts → BFF API client (typed fetch wrapper: bff<T>(), bffList<T>(), bffOrThrow<T>())
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary) — calls BFF API
│ ├── books.ts → Generic book data layer (replaces 29 individual book files)
│ ├── constants.ts → Shared constants (BOOK_NAMES, CC_LILAS)
│ ├── og.ts → OG image metadata builders (ogForVerse, ogForLetter, etc.)
│ ├── text-highlight.ts → Text fragment encode/decode, highlight matching
│ ├── source-reference.ts → Source reference from URL (for copy attribution)
│ ├── disciples.ts → Disciples data access (by letter, place, year, search)
│ ├── letters.ts → Letters data access (by year, recipient, location)
│ ├── disciples.ts → Disciples data access — calls BFF API
│ ├── letters.ts → Letters data access (by year, recipient, location) — calls BFF API
│ ├── transcriptions.ts → Transcriptions data access — calls BFF API
│ ├── utils.ts → cn() helper
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.)
│ ├── other-documents.ts → Other Documents data access
│ ├── journals.ts → Journals data access
│ └── magazines.ts → Magazines data access
├── prisma/
│ └── schema.prisma → All models (Verse, BookMeta, ProseChapter, Essay, Letter,
│ GlossaryEntry, Disciple, Transcription, etc.)
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.) — calls BFF API
│ ├── other-documents.ts → Other Documents data access — calls BFF API
│ ├── journals.ts → Journals data access — calls BFF API
│ └── magazines.ts → Magazines data access — calls BFF API
└── public/
├── fonts/Noto_Sans/static/ → NotoSans-Regular.ttf + NotoSans-Bold.ttf (OG images)
├── images/ → OG card image (Prabhupāda)
@@ -121,199 +135,50 @@ extractor/ → Extraction & seeding tools (not deployed)
---
## Database Schema (Prisma + MongoDB)
## Data Architecture
```prisma
type Synonym {
word String
meanings String[]
}
The knowledgebase **never connects to MongoDB directly**. All data is served by the **Go BFF API** server.
model Verse {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String // "bg" | "sb" | "cc" | "sb1962" | "iso" | "noi"
division String? // null for BG/SB1962/ISO/NOI; canto for SB; lila for CC
chapter Int
verse Int
verseEnd Int?
reference String
bookTitle String
devanagari String
bengali String? // only CC has Bengali
transliteration String
synonyms Synonym[]
translation String
purport String[]
### BFF Server
@@unique([book, division, chapter, verse])
@@index([book])
@@index([book, division])
@@index([book, division, chapter])
@@map("verses")
}
- **URL**: Configured via `BFF_URL` environment variable (default: `http://localhost:8080`)
- The BFF reads from MongoDB — the knowledgebase frontend has no database driver or connection string
- All API endpoints live under `/api/v1/...` on the BFF server
model BookMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
### BFF Client (`lib/bff.ts`)
@@map("book_meta")
}
The typed fetch wrapper provides three helpers:
model ProseChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
volume Int // 0 = no volumes (sentinel for non-volume books)
chapter Int? // null for intro segments
title String
type String // "chapter" | "intro-segment" | "conclusion"
slug String
order Int
paragraphs String[]
- **`bff<T>(path, options?)`** — Fetch JSON from the BFF. Returns `null` on 404, throws `BffError` on other failures.
- **`bffList<T>(path, options?)`** — Fetch a list. Returns `[]` on 404 (instead of null).
- **`bffOrThrow<T>(path, options?)`** — Fetch JSON, throwing on 404. Use when a missing resource should be an error.
@@unique([book, volume, slug])
@@index([book])
@@index([book, volume])
@@index([book, volume, type])
@@map("prose_chapters")
}
Options include `params` (query parameters), `revalidate` (Next.js ISR seconds, default 60), and `tags` (cache tags).
model Essay {
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
title String
author String
year Int?
group String? // e.g., "two-essays"
order Int
paragraphs String[]
### Response Envelope
@@index([group])
@@map("essays")
}
BFF responses use a `{ success: true, data: ... }` envelope. The `bff()` client auto-unwraps this — callers receive the inner `data` value directly.
model OtherDocumentMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
### Case Mapping
@@map("other_document_meta")
}
The BFF uses **snake_case** JSON field names (Go convention). Each frontend data layer file (`lib/letters.ts`, `lib/transcriptions.ts`, `lib/books.ts`, etc.) maps BFF responses to **camelCase** TypeScript types used by React components.
model OtherDocumentChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
chapter Int
title String
type String
slug String
order Int
paragraphs String[]
### Data Layer Files
@@unique([book, slug])
@@index([book])
@@map("other_document_chapters")
}
Individual `lib/*.ts` files are thin wrappers that:
1. Call a BFF endpoint via `bff()` / `bffList()` / `bffOrThrow()`
2. Map the snake_case response to camelCase frontend types
3. Export typed functions consumed by Next.js server components
model JournalMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("journal_meta")
}
model JournalChapter {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
chapter Int
title String
type String
slug String
order Int
paragraphs String[]
@@unique([book, slug])
@@index([book])
@@map("journal_chapters")
}
model MagazineMeta {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String @unique
data Json
@@map("magazine_meta")
}
model MagazineIssue {
id String @id @default(auto()) @map("_id") @db.ObjectId
book String
volume Int
part Int
title String
slug String
order Int
paragraphs String[]
paragraphsJson String? // JSON string fallback for large issues (BSON wire protocol workaround)
@@unique([book, slug])
@@index([book])
@@index([book, volume])
@@map("magazine_issues")
}
model Letter {
id String @id @default(auto()) @map("_id") @db.ObjectId
slug String @unique
recipients String[] // array of individual recipient names
recipientDisplay String // original combined string for display
date String?
dateRef String?
year Int?
month Int?
day Int?
location String?
salutation String?
paragraphs String[]
@@index([year])
@@index([recipients])
@@index([location])
@@index([year, month])
@@map("letters")
}
model GlossaryEntry {
id String @id @default(auto()) @map("_id") @db.ObjectId
term String
definition String
definitionHtml String
@@unique([term])
@@map("glossary")
}
model Disciple {
id String @id @default(auto()) @map("_id") @db.ObjectId
initiatedName String
suffix String // "Dasa" | "Devi Dasi" | "Sw"
initiationDate DateTime? // 1st of the month (exact day unknown unless verified)
initiationPlace String?
karmiName String? // pre-initiation (birth) name
avatar String? // URL to profile photo
about String? // biographical notes
tags String[]
isVerified Boolean @default(false)
@@index([initiatedName])
@@index([initiationPlace])
@@index([initiationDate])
@@index([suffix])
@@index([tags])
@@map("disciples")
}
```
Key data layers and their BFF endpoint prefixes:
- `lib/api.ts` → `/api/v1/books/*`, `/api/v1/glossary/*` — Books, verses, prose chapters, glossary
- `lib/books.ts` → `/api/v1/books/{code}/*` — Generic book data layer (replaces 29 individual per-book files)
- `lib/letters.ts` → `/api/v1/letters/*` — Letters (by year, recipient, location)
- `lib/transcriptions.ts` → `/api/v1/transcriptions/*` — Transcriptions (by year, place, type)
- `lib/disciples.ts` → `/api/v1/disciples/*` — Disciples directory
- `lib/other-documents.ts` → `/api/v1/other-documents/*` — Other Documents
- `lib/journals.ts` → `/api/v1/journals/*` — Journals
- `lib/magazines.ts` → `/api/v1/magazines/*` — Magazines
---
@@ -459,15 +324,29 @@ Seed script: `npx tsx scripts/seed-disciples.ts`
**Schema notes**:
- `recipients` is an array of individual names (e.g., `["Mukunda", "Janaki"]`)
- `recipientDisplay` is the original combined string (e.g., `"Mukunda, Janaki"`)
- Searching for a recipient uses Prisma `{ has: name }` to match any letter where that person appears
- Searching for a recipient filters via the BFF endpoint (matches any letter where that person appears)
- Multi-recipient groups are split by `,`, `;`, ` and `, ` & `
- Trailing punctuation (`:`, `,`, `.`) cleaned from names
### Transcriptions (stored in `transcriptions` collection)
3,787 transcriptions of Śrīla Prabhupāda's lectures and conversations (1966–1977), plus 59 philosophy discussions and 12 SB dictations.
- **Lectures** (2,179): Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta, Nectar of Devotion, Īśopaniṣad, Brahma-saṁhitā, and general lectures
- **Conversations** (1,537): Morning walks, room conversations, interviews, press conferences, initiations
- **Philosophy Discussions** (59): Western philosophers discussed with Śyāmasundara and Hayagrīva
- **SB Dictations** (12): Śrīmad-Bhāgavatam 3rd Canto translation/purport dictations
- **Audio**: 99.1% have linked audio recordings from Cloudflare R2 (`audioUrl` field)
- **Empty content**: 30 transcriptions (Hindi/Bengali lectures) have no English text but may have audio
Data layer: `lib/transcriptions.ts` (calls BFF API, NOT local JSON files or direct MongoDB)
### Other
| Content | Collection |
|---------|-----------|
| Glossary (2,749 entries) | `glossary` |
| Transcriptions (3,787 entries) | `transcriptions` collection |
---
@@ -582,6 +461,27 @@ IAST transliteration → Devanagari via `indic-transliteration` library. Key fix
| `/letters/location/Los%20Angeles` | All letters from a specific location |
| `/letters/[slug]` | Individual letter reader with prev/next navigation |
### Transcriptions
```
/transcriptions → Browse by year / place / type
/transcriptions?view=year → Year grid view
/transcriptions?view=place → Place grid view
/transcriptions?view=type → Lecture vs Conversation cards
/transcriptions/year/[year] → Year listing, grouped by month
/transcriptions/place/[place] → Place listing
/transcriptions/type/[type] → Type listing (lecture/conversation), year sidebar
/transcriptions/[slug] → Individual transcription reading page
```
Each transcription page shows:
- Title, type badge (Lecture/Conversation), date, place, reference, duration
- Participants list
- Play Audio button (links to Cloudflare R2 mp3)
- Content blocks: paragraphs with speaker labels, verses (centered italic), quotes (border-left), notes
- Prev/next navigation within same year
- `?hl=` text highlight sharing support with OG image
### Other routes
| Route | Content |
@@ -635,11 +535,17 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
### Reading Settings (accessibility)
- **Theme**: Light / Dark / System toggle
- **Palette**: 3 light + 3 dark colour palettes, each selectable from a swatch grid in the settings panel
- Light: **Aged Parchment** (warm cream/saffron — default), **Tulasī Garden** (sage green/emerald), **Yamunā Mist** (sky-blue/cerulean)
- Dark: **Ancient Manuscript** (warm brown/saffron — default), **Nīlamaṇi** (deep navy/sky-blue — Kṛṣṇa's "blue jewel"), **Vraja Twilight** (deep wine/rose-red)
- Swatches in the settings panel show only the palettes for the currently resolved theme (uses `resolvedTheme` from next-themes, so System mode resolves correctly)
- Each palette defines a full set of CSS variables (background, foreground, primary, card, muted, accent, border, etc.) so the entire UI adapts cohesively
- Palette CSS lives in `app/globals.css` under `:root:not(.dark)[data-light-palette="..."]` and `.dark[data-dark-palette="..."]` selectors. The `:not(.dark)` scoping prevents light-palette attribute rules from overriding `.dark` (an attribute selector has higher specificity than a plain class selector)
- **Serif Font**: Toggle to switch reading content to Georgia/serif
- **Font Size**: Slider (XS → XXL) using CSS `zoom` on `main` and `aside`
- **Justify Text**: Toggle for justified text alignment on reading content
- Settings persist in `localStorage` under `spsays_reading_prefs`
- Applied via CSS classes on `<html>` element
- Settings persist in `localStorage` under `spsays_reading_prefs` (now includes `lightPalette` and `darkPalette` fields)
- Applied via CSS classes (`reading-serif`, `reading-fs-*`, `reading-justified`) AND data attributes (`data-light-palette`, `data-dark-palette`) on `<html>` element
### Mobile Navigation
- Left-slide drawer (vaul) triggered by a menu button next to the Previous button
@@ -647,14 +553,80 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
- Shows the full sidebar navigation grouped by section
- Implemented in all 24+ reader pages via shared `MobileNav` component
### Page Header (`components/page-header.tsx`)
- Shared client component used on every page — wraps the title/breadcrumb area and the right-side actions (search button + reading settings)
- **Mobile layout** uses CSS `float-right` on the actions block so the title text genuinely wraps around them on the first line and uses the **full container width** on every subsequent line. (CSS `padding-right` was previously used but applies the same gap to every line, leaving an empty column down the right edge.)
- When the header search is expanded on mobile, the actions block drops the float, the logo/title area is hidden via `max-md:hidden`, and the search bar fills the full header width edge-to-edge (no `ml-2` margin in the expanded state)
- **Desktop layout** is unchanged — standard `md:flex md:items-start md:justify-between md:gap-4` with the actions block on the right (`md:order-1 md:shrink-0`)
- Reads a sessionStorage flag (`spsays_from_search`) on first render via a lazy `useState` initialiser; if set, renders a “← Back to search results” button that calls `router.back()` and **hides the page's own first-child `<Link>` breadcrumb** via `[&>a:first-child]:hidden` so there's never a double-arrow stack
- The lazy initialiser also clears the flag on read (one-shot), so navigating from the result page to a subsequent page (e.g. next verse) doesn't show the search return button again
### Search
**Search** (`/search` page) — Typo-tolerant, diacritic-insensitive full-text search across all content.
- **Powered by Typesense** via the BFF (`/api/v1/search` proxied through `/api/search`)
- **9 indexed content types** combined into 8 user-facing filters: Books (verses + prose), Letters, Transcriptions, Glossary, Essays, Journals, Magazines, Other Documents
- **Books filter** combines verses and prose into one tab (sends `type=verses,prose` to the BFF)
- **Multi-select filters** — clicking multiple type tabs combines them (e.g., Books + Letters); clicking "All" clears all filters
- **URL state** — `q`, `type`, and `page` are synced to the URL (e.g. `/search?q=foo&type=books,letters&page=2`), so any search is shareable and bookmarkable
- **Pagination** — appears both above the results (compact: prev/next + page count) and below them
- **Result cards** — the entire card is a clickable `Link` that navigates to the source page
- **Header search** — `HeaderSearchButton` on every page header expands into a search input on click; the `/` keyboard shortcut opens the search input from any page
- **300ms debounce** on input
- **Highlighted snippets** — matched text wrapped in `<mark>` tags
- **Typo tolerance** — "Krishna", "Krsna", "Krishn" all find Kṛṣṇa results
- **Diacritic insensitivity** — `_ascii` shadow fields indexed alongside originals (e.g., "Yashodanandana" matches "Yaśodanandana")
- **Prefix matching** — partial words like "sarv dharman parit" match `sarva-dharmān parityajya`
- **Hyphenated terms** — every collection (not just verses) has `token_separators: ["-"]`, so `Dīkṣā-guru` indexes as two tokens (`dīkṣā` + `guru`) and a query for "diksa guru" matches it cleanly
- **Stopwords** — a server-side Typesense stopwords set (`common`) strips conversational tokens like `letter`, `to`, `of`, `essay`, `magazine`, etc. from queries so they don't drown out specific intent. e.g. "letter to tusta krsna" effectively searches for "tusta krsna" and surfaces actual letters to that recipient instead of any letter whose body contains "letter" + "to"
- **Exact > prefix > typo ranking** — `prioritize_exact_match=true` + `prioritize_token_position=true` so "karma" outranks "karmic", and exact recipient matches outrank typo matches
- **Glossary results** — glossary pages read `?term=` from the URL so a search result lands on the correct term (with diacritic-insensitive matching)
- **Defensive null handling** — the frontend always treats a null `results` payload as an empty array
- Files: `app/search/page.tsx`, `app/search/search-content.tsx`, `app/api/search/route.ts`, `components/header-search.tsx`
**Header search behaviour.** When the user submits a search from the inline header input, `HeaderSearchButton` automatically appends a `type` query param matching the section they're currently in (via `usePathname()`):
| Current path | Search URL gets | Active tab |
|---|---|---|
| `/books/**` | `type=books` | Books |
| `/letters/**` | `type=letters` | Letters |
| `/transcriptions/**` | `type=transcriptions` | Transcriptions |
| `/essays/**` | `type=essays` | Essays |
| `/glossary/**` | `type=glossary` | Glossary |
| `/journals/**` | `type=journals` | Journals |
| `/magazines/**` | `type=magazines` | Magazines |
| `/other-documents/**` | `type=other_documents` | Other Documents |
| `/`, `/search`, `/about` | no `type` param | All |
**Header search expand UX.** The collapsed icon expands into a 400px-wide bar on desktop (capped via `md:max-w-100`, animated to `width: 100%` of its container by framer-motion) and a full-width bar on mobile. The bar has a “Close” X on the left, the text input in the middle, and a primary-coloured “Search ⏎” submit button on the right — the `CornerDownLeft` icon hints that Enter also submits. Pressing Esc collapses. Spring animation on enter, instant collapse (no exit animation, no AnimatePresence — the previous use of `AnimatePresence mode="popLayout"` caused a backwards-shrink flash because `popLayout` pulled the exiting element out of flow and ran a layout-level FLIP).
**Back to search results.** When a user clicks a result card, `ResultCard`'s `onClick` writes a one-shot `spsays_from_search` flag to sessionStorage. On the destination page, `PageHeader` reads (and consumes) this flag in a lazy `useState` initialiser; if present, it replaces the page's own breadcrumb link with a “← Back to search results” button that calls `router.back()`. Client-side navigation preserves `AudioPlayerProvider` so audio playback continues uninterrupted.
**Search-result highlight.** Each result card builds its destination URL through `buildSearchResultUrl(result.url, result.snippet)` from `lib/text-highlight.ts`. This extracts the text inside the snippet's `<mark>` tags (from first mark to last mark, HTML stripped), encodes it via the existing `encodeTextFragment()` helper, and appends it as `?hl=...`. The destination page's existing `TextHighlighter` then finds and highlights the matched phrase — reusing the exact same `?hl=` infrastructure as share links, with no changes to `TextHighlighter` itself.
**Search relevance.** Search is tuned to find verses where the verse text itself contains the query, not commentaries that cite it. Verse purports are intentionally excluded from the default search so results stay focused on primary sources. In practice this means searching for `sarva dharman parityajya` returns BG 18.66 first (the actual verse) rather than the many commentaries that quote it.
### Per-Page Filters
- Books page: diacritic-aware search (typing "Krsna" matches "Kṛṣṇa")
- Glossary page: full-text search across terms and definitions
- Uses Unicode NFD normalization + manual IAST character stripping
### Color Palette
- **Light mode ("Aged Parchment")**: Warm cream backgrounds, deep sepia text, saffron/ochre primary
- **Dark mode ("Ancient Manuscript")**: Deep warm brown backgrounds, parchment text, golden saffron primary
### Color Palettes
Three themes per mode, all named after concepts from the Vedic tradition. Each defines a complete set of CSS variables (background, foreground, primary, card, muted, accent, border, ring, sidebar variants, charts) for cohesive styling site-wide.
**Light palettes**
- **Aged Parchment** (default) — warm cream backgrounds, deep sepia text, saffron/ochre primary
- **Tulasī Garden** — sage-green backgrounds, deep forest text, emerald primary; inspired by the sacred Tulasī plant
- **Yamunā Mist** — soft blue backgrounds, deep teal text, cerulean primary; inspired by the sacred Yamunā river
**Dark palettes**
- **Ancient Manuscript** (default) — deep warm brown backgrounds, parchment text, golden saffron primary
- **Nīlamaṇi** — deep navy backgrounds, soft moonlight text, sky-blue primary; “blue jewel,” one of Kṛṣṇa's epithets
- **Vraja Twilight** — deep wine backgrounds, warm cream text, rose-red primary; the crimson dusk over Vraja
Palettes are switched independently of theme: the user can pick their preferred light palette AND their preferred dark palette — the right one takes effect automatically when next-themes toggles between modes. Selection happens in `components/reading-settings.tsx` via a 3-up grid of swatch buttons (each swatch is a paint-chip-style square with the palette's background colour plus a small primary-coloured dot in the corner).
### Text Selection Toolbar
- Global floating toolbar appears on text selection across all reading pages (except RTFs)
@@ -676,6 +648,8 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
- Server-side `extractMatchedText()` searches paragraphs for OG image quote extraction
- Case-insensitive matching for both DOM highlighting and server-side text extraction
- Cross-paragraph matching via position mapping (spaces inserted at element boundaries)
- **HTML-entity regex must be strict.** `extractMatchedText()` strips HTML entities from paragraph strings before searching. The regex matches only real entity shapes — named (`&amp;`), decimal (`&#39;`), and hex (`&#x27;`) — not the loose `&[^;]+;` pattern, which would pair a stray `&` (e.g. "24 & 25 Oct") with a far-away `;` (e.g. "my beloved son;") and eat everything between them, breaking OG quote extraction for any selection that spans such a stray ampersand. The browser-side `findAndHighlightText` is unaffected because it walks DOM text nodes that the browser has already decoded.
- `buildSearchResultUrl(resultUrl, snippetHtml)` extracts the matched text from a search snippet's `<mark>` tags and appends `?hl=...` to the result URL so the destination page highlights the match — same infrastructure as share links.
### Dynamic OG Images
- `/api/og` route generates 1200×630 PNG images using `next/og` (Satori)
@@ -685,6 +659,31 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
- All 104 content pages have `generateMetadata` with contextual fallback descriptions
- When `?hl=` is present, the actual selected text is extracted from DB content for the OG quote
### Audio Player
A site-wide floating audio player for transcription recordings (and future music library). Built with framer-motion for animations.
- **Desktop**: Centered floating bar (max-width 680px) with cover image, track info, transport controls (prev/play/next), volume slider, source link, close button
- **Mobile**: Full player shows two rows: track info row + centered transport controls
- **Auto-minimize**: After 8 seconds of inactivity on **both mobile and desktop**, the player auto-minimizes to a floating pill. The minimized pill shows cover image with a circular progress ring and audio wave animation. Tap/click to expand.
- **X button behavior**: Minimizes the player if audio is playing; stops playback entirely if audio is paused
- **Clicking outside the player**: Minimizes it (does not stop playback)
- **Z-index**: `z-40` — below text selection toolbar (z-80), drawers (z-90), modals (z-100)
- **Hidden when selection toolbar is active** on mobile to avoid overlap
- **Forced dark surface** (`bg-[#1c1c1e]`) for accessibility contrast regardless of theme
- **Keyboard shortcuts**: Space (play/pause), ←/→ (seek ±10s), ↑/↓ (volume), M (mute), Esc (close)
- **Progress seeker**: 8px tall bar at top of player, click/drag to seek
- **Volume**: Persisted to localStorage. Mobile relies on hardware volume buttons
- **framer-motion**: Spring animations for enter/exit, AnimatePresence for minimize/expand transitions, motion.div for audio wave bars
Components:
- `components/audio-player/audio-context.tsx` — React context + provider, manages `<audio>` element
- `components/audio-player/floating-player.tsx` — Floating player UI
- `components/audio-player/audio-wave.tsx` — Reusable animated audio wave bars
- `components/audio-player/index.ts` — Barrel exports
The `AudioPlayerProvider` wraps the entire app in `layout.tsx`. The `FloatingPlayer` is rendered before `SiteFooter`.
### Site Footer
- Global footer on every page with © year, About link, Home link
@@ -696,30 +695,32 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
## Environment Variables
```
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org
BFF_URL=http://localhost:8080 # Go BFF API server URL (default: http://localhost:8080)
ALLOW_RTF=true # Enable RTF viewer (development only)
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org # Public site URL for OG images
```
> No new vars are needed for the frontend search feature — search goes through the BFF, which has `TYPESENSE_URL` and `TYPESENSE_API_KEY` configured server-side.
---
## How to Run
```bash
# Development (from knowledgebase/)
npm run dev
# 1. Start the Go BFF server (required — all data comes from the BFF)
cd spsays-bff && go run cmd/api/main.go # Starts on :8080 by default
# Prisma
npx prisma generate # Regenerate Prisma client after schema changes
# 2. Development (from knowledgebase/)
npm run dev # Next.js dev server with Turbopack
# Build
# 3. Build
npx next build
# Extraction & seeding scripts are in extractor/ — see extractor/scripts/
# BFF API server (from ../spsays-bff/)
make run # Build + start Go API on :8080
```
> **Note**: No `npx prisma generate`, `npx prisma db push`, or MongoDB connection string needed in the frontend. The BFF server must be running for any page to load data.
---
## Verse JSON Schema (BG/SB/CC/SB1962/ISO/NOI)
@@ -803,11 +804,47 @@ make run # Build + start Go API on :8080
Glossary is browsed via `/glossary/{letter}` routes (A-Z). Each letter page shows terms grouped by base letter (diacritical variants mapped: Ā→A, Ś→S, Ṛ→R, etc.). Single entry view with prev/next navigation.
## Transcription JSON Schema
```json
{
"slug": "720911sb-los-angeles",
"name": "Śrīmad-Bhāgavatam 1.2.6",
"type": "lecture",
"date": "September 11, 1972",
"year": 1972,
"month": 9,
"day": 11,
"place": "Los Angeles",
"context": null,
"reference": "SB 1.2.6",
"durationMinutes": 45,
"participants": ["Prabhupāda", "Devotees"],
"audioUrl": "https://s3.prabhupadasays.com/sp-audio/...",
"content": [
{
"text": "So Kṛṣṇa consciousness movement...",
"format": "paragraph",
"alignment": "left",
"speaker": "Prabhupāda"
},
{
"text": "<i>oṁ namo bhagavate...</i>",
"format": "verse",
"alignment": "center",
"speaker": null
}
]
}
```
Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `"quote"` (translation/purport), `"heading"` (TEXT/TRANSLATION/PURPORT labels), `"note"` (editorial notes).
---
## Known Issues
1. **MongoDB BSON wire corruption**: The MongoDB server occasionally corrupts large BSON documents. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time.
1. **BFF dependency** — The knowledgebase requires the Go BFF server to be running; all pages will fail to load data if the BFF is down.
2. **SB verse numbering**: 5 duplicate verse reference tags in the HTML. Fixed by using the TEXT N header as authoritative.
@@ -817,4 +854,12 @@ Glossary is browsed via `/glossary/{letter}` routes (A-Z). Each letter page show
5. **CSS zoom for font scaling**: `zoom` property works in Chrome/Safari/Edge but may not work in older Firefox (< 126). A `transform: scale()` fallback is provided via `@supports not (zoom: 1.1)`.
6. **Password protection requires ACCESS_KEY at runtime**: The `PasswordGate` component checks the server at runtime via `/api/verify-access`. The env var must be available when the server is running, not just at build time.
6. **Transcription BSON fallback** — 1 document stores content as `contentJson` string due to BSON wire protocol limits; `getTranscription()` transparently parses it
7. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
8. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
9. **Search depends on Typesense** — Search depends on Typesense being available via the BFF; if Typesense is down, search will return empty results but the rest of the site is unaffected.
10. **Palette FOUC on first load** — Palette data attributes are applied via the `applyClasses` effect after first paint. A user with a non-default palette will see a brief flash of the default palette before their preference kicks in. Could be fixed by injecting an inline script in `layout.tsx` that reads `localStorage` before hydration (next-themes does this for the `.dark` class). Not yet implemented.
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { auth, AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await auth.forgotPassword(email);
setSubmitted(true);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Something went wrong.");
} finally {
setSubmitting(false);
}
}
if (submitted) {
return (
<AuthCard
title="Check your inbox"
description="If an account exists for that email, we've sent a reset link. It will expire in 1 hour."
footer={<AuthFooterLink prefix="Back to" href="/login" label="log in" />}
>
<p className="text-sm text-muted-foreground">
Didn&apos;t get an email? Check your spam folder or try again in a minute.
</p>
</AuthCard>
);
}
return (
<AuthCard
title="Reset your password"
description="Enter your email and we'll send you a reset link."
footer={<AuthFooterLink prefix="Remembered it?" href="/login" label="Log in" />}
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Sending…" : "Send reset link"}
</Button>
</form>
</AuthCard>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
/**
* Shared layout for the auth pages (login, signup, forgot-password,
* reset-password). Provides the centered card frame so each page only
* has to render its own form.
*
* Also gates the routes: a signed-in user landing on any /login, /signup,
* etc. URL is bounced to /u/me (or the `next` query param if present).
* The check happens client-side because auth state lives in
* AuthProvider — there's no server-side cookie inspection here yet, and
* the redirect is purely cosmetic (logged-in users don't gain anything
* from re-seeing these forms, and signing in over an existing session
* would just no-op).
*/
import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/components/auth/auth-provider";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
// useSearchParams forces a CSR bailout during prerender, so Next.js
// requires a Suspense boundary anywhere it's read. The gate is the
// only consumer here — isolate it so the rest of the layout can still
// SSR normally.
return (
<main className="flex items-center justify-center px-4 py-12 min-h-[90vh]">
<Suspense fallback={<AuthShell>{children}</AuthShell>}>
<AuthGate>{children}</AuthGate>
</Suspense>
</main>
);
}
function AuthShell({ children }: { children: React.ReactNode }) {
return <div className="w-full max-w-md">{children}</div>;
}
function AuthGate({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
const params = useSearchParams();
useEffect(() => {
if (loading || !user) return;
const next = params.get("next");
router.replace(next && next.startsWith("/") ? next : "/u/me");
}, [loading, user, router, params]);
// Don't flash the form for an instant before the redirect fires.
if (!loading && user) {
return <p className="text-sm text-muted-foreground">Redirecting…</p>;
}
return <AuthShell>{children}</AuthShell>;
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { LoginForm } from "@/components/auth/login-form";
function LoginContent() {
const router = useRouter();
const params = useSearchParams();
const next = params.get("next") ?? "/u/me";
const oauthError = params.get("oauth_error");
return (
<AuthCard
title="Welcome back"
description="Log in to access your bookmarks, notes, and collections."
footer={
<AuthFooterLink prefix="No account?" href="/signup" label="Create one" />
}
>
{oauthError ? (
<p className="text-sm text-destructive">
{oauthErrorMessage(oauthError)}
</p>
) : null}
<LoginForm
oauthNext={next}
onSuccess={() => {
router.push(next);
router.refresh();
}}
/>
</AuthCard>
);
}
function oauthErrorMessage(code: string): string {
switch (code) {
case "invalid_state":
case "missing_state":
return "Your sign-in link expired. Please try again.";
case "exchange_failed":
case "profile_failed":
return "Sign-in failed while talking to the provider. Please try again.";
case "unknown_provider":
return "That sign-in provider isn't configured. Please use email or another method.";
case "unavailable":
return "Social sign-in is temporarily unavailable. Please use email/password.";
case "server":
return "Something went wrong on our side. Please try again in a moment.";
default:
return "Sign-in failed. Please try again.";
}
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginContent />
</Suspense>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { auth, AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function ResetPasswordForm() {
const params = useSearchParams();
const router = useRouter();
const token = params.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
if (!token) {
return (
<AuthCard title="Invalid reset link" description="The link is missing a token. Please request a new one.">
<AuthFooterLink prefix="Need a new link?" href="/forgot-password" label="Request one" />
</AuthCard>
);
}
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setSubmitting(true);
try {
await auth.resetPassword(token, password);
setDone(true);
setTimeout(() => router.push("/login"), 1500);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Reset failed.");
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<AuthCard
title="Password updated"
description="Redirecting you to the login page…"
>
<AuthFooterLink prefix="Take me there now —" href="/login" label="Log in" />
</AuthCard>
);
}
return (
<AuthCard
title="Set a new password"
description="Choose a strong password you haven't used before."
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
autoComplete="new-password"
minLength={8}
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Updating…" : "Update password"}
</Button>
</form>
</AuthCard>
);
}
export default function ResetPasswordPage() {
// useSearchParams must be inside Suspense for Next.js streaming.
return (
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
import { SignUpForm } from "@/components/auth/signup-form";
export default function SignUpPage() {
const router = useRouter();
return (
<AuthCard
title="Create your account"
description="Save bookmarks, build collections, and follow Śrīla Prabhupāda's words across devices."
footer={
<AuthFooterLink prefix="Already registered?" href="/login" label="Log in" />
}
>
<SignUpForm
oauthNext="/u/me"
onSuccess={() => {
router.push("/u/me");
router.refresh();
}}
/>
</AuthCard>
);
}
+34 -10
View File
@@ -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:
@@ -51,7 +67,7 @@ export function generateMetadata(): Metadata {
return buildOgMeta({
title: "About — Prabhupāda Says",
quote:
"Built by a small team of Prabhupādānugas — preserving and presenting the teachings of Śrīla Prabhupāda exactly as he gave them, without alteration.",
"Built by a small team of Rupānugas — preserving and presenting the teachings of Śrīla Prabhupāda exactly as he gave them, without alteration.",
});
}
@@ -80,7 +96,7 @@ export default function AboutPage() {
<span className="font-medium text-foreground">
Prabhupāda Says
</span>{" "}
is built by a small team of Prabhupādānugas — devotees dedicated
is built by a small team of Rupānugas. We are dedicated
to preserving and presenting the teachings of His Divine Grace
A.C. Bhaktivedanta Swami Prabhupāda exactly as he gave them,
without alteration, reinterpretation, or institutional bias.
@@ -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>
) : (
@@ -60,7 +60,6 @@ export default async function BbdPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -3,7 +3,7 @@ import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
@@ -162,14 +162,7 @@ function SidebarLink({
item: BkSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -86,7 +86,7 @@ export function BooksGrid({ books }: { books: BookInfo[] }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search books..."
placeholder="Search books by name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
@@ -11,7 +11,7 @@ import {
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
@@ -118,7 +118,6 @@ export default async function BsPage() {
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -133,7 +132,6 @@ export default async function BsPage() {
{foreword.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{foreword.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -162,14 +162,7 @@ function SidebarLink({
item: CbhcSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -5,7 +5,7 @@ import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
@@ -390,7 +390,11 @@ export function ChapterViewer({
return (
<>
{/* ---- Sidebar: Verse Index ---- */}
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for
the site footer so the sidebar doesn't slide behind it when the
page is scrolled to the bottom. (89 sticky-top + 76 bottom gap
= 165, hence calc(100vh - 165px).) */}
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-165px)] overflow-y-auto py-8 pr-4">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Verses
</h2>
@@ -63,7 +63,6 @@ export default async function DsPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -63,7 +63,6 @@ export default async function EjPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function EkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -217,14 +217,7 @@ function SidebarLink({
item: GgSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -64,7 +64,6 @@ export default async function GgPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -126,7 +125,6 @@ export default async function GgPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function HkhPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -50,8 +50,11 @@ export async function generateMetadata({
const isVerse_ = content && "mantra" in content && "devanagari" in content;
const paragraphs = content
? isVerse_
? [(content as IsoVerse).translation, ...(content as IsoVerse).purport]
: (content as IsoIntroSegment).paragraphs
? [
(content as unknown as IsoVerse).translation,
...(content as unknown as IsoVerse).purport,
]
: (content as unknown as IsoIntroSegment).paragraphs
: undefined;
const quote = extractQuoteFromHl(sp, paragraphs);
@@ -64,7 +67,7 @@ export async function generateMetadata({
}
const title = isVerse_
? `${(content as IsoVerse).reference}: ${(content as IsoVerse).title}`
? `${(content as unknown as IsoVerse).reference}`
: content && "title" in content
? (content as IsoIntroSegment).title
: `Mantra ${slug}`;
@@ -110,7 +113,7 @@ export default async function IsoMantraPage({
{verse && (
<>
<span className="text-primary">{verse.reference}: </span>
{verse.title}
{verse.bookTitle}
</>
)}
{intro && intro.title}
@@ -68,7 +68,6 @@ export default async function IsoPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{frontPages.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -85,7 +84,6 @@ export default async function IsoPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -164,7 +162,6 @@ export default async function IsoPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{aboutAuthor.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -72,7 +72,6 @@ export default async function KbVolumePage({
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -106,7 +105,6 @@ export default async function KbVolumePage({
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{ch.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -221,14 +221,7 @@ function SidebarLink({
item: LclSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -64,7 +64,6 @@ export default async function LclPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -126,7 +125,6 @@ export default async function LclPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function LobPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function MgPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -10,7 +10,7 @@ import {
type VerseIndexEntry,
type VerseRecord,
} from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import type { Metadata } from "next";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
@@ -116,7 +116,6 @@ export default async function MmPage() {
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function MogPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -14,7 +14,7 @@ import {
type VerseRecord,
} from "@/lib/api";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
type NbsMeta = {
@@ -116,7 +116,6 @@ export default async function NbsPage() {
{introduction.title}
</p>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{introduction.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -203,14 +203,7 @@ function SidebarLink({
item: NodSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -70,7 +70,6 @@ export default async function NodPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -65,7 +65,6 @@ export default async function NoiPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{frontPages.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -82,7 +81,6 @@ export default async function NoiPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{preface.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -141,7 +139,6 @@ export default async function NoiPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{appendixes.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function OchkmPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function OwkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function PoyPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -239,14 +239,7 @@ function SidebarLink({
item: PqpaSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -64,7 +64,6 @@ export default async function PqpaPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -151,7 +150,6 @@ export default async function PqpaPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -60,7 +60,6 @@ export default async function RvPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -5,7 +5,7 @@ import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
@@ -3,7 +3,7 @@ import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { notFound } from "next/navigation";
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
import { ChapterViewer } from "@/app/books/chapter-viewer";
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
import type { MobileNavGroup } from "@/components/mobile-nav";
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
@@ -64,7 +64,6 @@ export default async function SsrPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -123,7 +122,6 @@ export default async function SsrPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -64,7 +64,6 @@ export default async function SvaPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -123,7 +122,6 @@ export default async function SvaPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -141,6 +141,31 @@ function TlcSidebar({
const introItems = items.filter((item) => item.type === "intro-segment");
const chapterItems = items.filter((item) => item.type === "chapter");
// Group chapters by part (if parts exist)
const hasParts = chapterItems.some((item) => item.partNumber != null);
const partGroups: {
partNumber: number;
partTitle: string;
chapters: TlcSidebarItem[];
}[] = [];
if (hasParts) {
const partMap = new Map<
number,
{ partTitle: string; chapters: TlcSidebarItem[] }
>();
for (const item of chapterItems) {
const pn = item.partNumber ?? 0;
const pt = item.partTitle ?? `Part ${pn}`;
if (!partMap.has(pn)) partMap.set(pn, { partTitle: pt, chapters: [] });
partMap.get(pn)!.chapters.push(item);
}
for (const [partNumber, { partTitle, chapters }] of partMap) {
partGroups.push({ partNumber, partTitle, chapters });
}
partGroups.sort((a, b) => a.partNumber - b.partNumber);
}
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
{/* Front Matter */}
@@ -161,23 +186,40 @@ function TlcSidebar({
</>
)}
{/* Chapters */}
{chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
{/* Chapters — grouped by part if available */}
{hasParts
? partGroups.map((group) => (
<div key={group.partNumber} className="mb-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
{group.partTitle}
</h2>
<nav className="flex flex-col gap-0.5">
{group.chapters.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</div>
))
: chapterItems.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Chapters
</h2>
<nav className="flex flex-col gap-0.5">
{chapterItems.map((item) => (
<SidebarLink
key={item.slug}
item={item}
isActive={item.slug === currentSlug}
/>
))}
</nav>
</>
)}
</aside>
);
}
@@ -189,14 +231,7 @@ function SidebarLink({
item: TlcSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -68,7 +68,6 @@ export default async function TlcPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -226,14 +226,7 @@ function SidebarLink({
item: TlkSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -64,7 +64,6 @@ export default async function TlkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -126,7 +125,6 @@ export default async function TlkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -226,14 +226,7 @@ function SidebarLink({
item: TqkSidebarItem;
isActive: boolean;
}) {
let label: string;
if (item.type === "chapter" && item.chapter != null) {
// Extract just the title portion after "Chapter N: "
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
label = `${item.chapter}. ${titlePart}`;
} else {
label = item.label;
}
const label = item.label;
return (
<Link
@@ -64,7 +64,6 @@ export default async function TqkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
@@ -126,7 +125,6 @@ export default async function TqkPage() {
</p>
</div>
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
{seg.paragraphs.length} paragraphs
</span>
</Link>
</li>
+96
View File
@@ -0,0 +1,96 @@
"use client";
/**
* Public collection at /c/[id]. Anyone can view if `is_public=true`.
* Private collections return 403 from the BFF, which we treat as "not
* found" to avoid leaking existence.
*/
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Globe, Lock, FolderPlus } from "lucide-react";
import {
collections,
SocialError,
type Collection,
type Post,
} from "@/lib/social";
import { PostViews } from "@/components/social/post-views";
export default function PublicCollectionPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = use(props.params);
const [c, setC] = useState<Collection | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [missing, setMissing] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
if (cancelled) return;
setC(collection);
setPosts(posts ?? []);
})
.catch((err) => {
if (
err instanceof SocialError &&
(err.status === 404 || err.status === 403)
) {
setMissing(true);
}
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [id]);
if (missing) notFound();
if (loading || !c)
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 py-12">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6 py-10 space-y-8">
<header className="rounded-2xl bg-card ring-1 ring-foreground/10 p-6 sm:p-8">
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{c.is_public ? <Globe className="size-3.5" /> : <Lock className="size-3.5" />}
{c.is_public ? "Public collection" : "Private collection"}
</div>
<h1 className="mt-3 font-[family-name:var(--font-title)] font-semibold text-3xl sm:text-4xl text-foreground leading-tight">
{c.title}
</h1>
{c.description ? (
<p className="mt-3 text-base text-muted-foreground leading-relaxed">
{c.description}
</p>
) : null}
<p className="mt-4 text-xs text-muted-foreground tabular-nums">
{c.item_count} post{c.item_count === 1 ? "" : "s"}
</p>
</header>
<section className="space-y-3">
{posts.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<FolderPlus className="size-6 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground mt-2">
This collection has no items yet.
</p>
</div>
) : (
<PostViews posts={posts} />
)}
</section>
</main>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
/**
* /chat — placeholder for the Kautilya AI study companion.
*
* The actual chat surface (an AI tuned on Śrīla Prabhupāda's books,
* letters, and lectures) isn't built yet. Until it ships we render a
* coming-soon page so the nav entries (sidebar + bottom bar) have a
* real route to point at. When the feature lands, swap this file for
* the real chat UI — nav wiring stays the same.
*/
import Link from "next/link";
import { Compass } from "lucide-react";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
export default function ChatPage() {
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24">
<div className="flex flex-col items-center text-center gap-5">
<span
aria-hidden
className="inline-flex size-14 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-7" />
</span>
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight text-balance">
Kautilya is coming soon
</h1>
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
An AI study companion tuned on Śrīla Prabhupāda&apos;s books,
letters, and lectures — ask any question, get answers grounded in
his own words with citations back to the source passages. Until
it lands, the full library is already waiting.
</p>
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
<Link
href="/search"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-5 h-11 text-sm font-medium hover:bg-primary/90 transition-colors min-w-[12rem]"
>
<Compass className="size-4" />
Explore Knowledgebase
</Link>
</div>
</div>
</main>
);
}
@@ -10,8 +10,7 @@ import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// HTML sanitisation (same pattern used across all readers)
@@ -31,25 +30,6 @@ function sanitizeHtml(html: string): string {
return clean;
}
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
let result: string[];
if (typeof paragraphs === "string") {
try {
const parsed = JSON.parse(paragraphs);
result = Array.isArray(parsed) ? parsed : [paragraphs];
} catch {
result = [paragraphs];
}
} else if (Array.isArray(paragraphs)) {
result = paragraphs as string[];
} else {
result = [];
}
return result.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
@@ -69,7 +49,14 @@ type SidebarItem = {
};
async function getEssay(slug: string): Promise<EssayData | null> {
const doc = await prisma.essay.findUnique({ where: { slug } });
const doc = await bff<{
slug: string;
title: string;
author: string;
year: number | null;
group: string | null;
paragraphs: string[];
}>(`/essays/${slug}`);
if (!doc) return null;
return {
slug: doc.slug,
@@ -77,15 +64,12 @@ async function getEssay(slug: string): Promise<EssayData | null> {
author: doc.author,
year: doc.year,
group: doc.group,
paragraphs: normalizeParagraphs(doc.paragraphs),
paragraphs: (doc.paragraphs ?? []).map(sanitizeHtml),
};
}
async function getEssaySidebar(): Promise<SidebarItem[]> {
return prisma.essay.findMany({
orderBy: [{ group: "asc" }, { order: "asc" }],
select: { slug: true, title: true },
});
return bffList<SidebarItem>("/essays");
}
// ---------------------------------------------------------------------------
@@ -91,7 +91,7 @@ export function EssaysGrid({ essays }: { essays: EssayInfo[] }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search essays..."
placeholder="Search essays by title..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
@@ -4,7 +4,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og";
import { prisma } from "@/lib/db";
import { bffList } from "@/lib/bff";
import { EssaysGrid } from "./essays-grid";
export function generateMetadata(): Metadata {
@@ -17,10 +17,12 @@ export function generateMetadata(): Metadata {
}
async function getAllEssays() {
return prisma.essay.findMany({
orderBy: [{ group: "asc" }, { order: "asc" }],
select: { slug: true, title: true, year: true, group: true },
});
return bffList<{
slug: string;
title: string;
year: number | null;
group: string | null;
}>("/essays");
}
export default async function EssaysPage() {

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