Author SHA1 Message Date
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
180 changed files with 14340 additions and 15353 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>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* 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.
*/
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="flex items-center justify-center px-4 py-12 min-h-[90vh]">
<div className="w-full max-w-md">{children}</div>
</main>
);
}
+36
View File
@@ -0,0 +1,36 @@
"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();
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" />
}
>
<LoginForm
onSuccess={() => {
router.push(params.get("next") ?? "/u/me");
router.refresh();
}}
/>
</AuthCard>
);
}
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>
);
}
+25
View File
@@ -0,0 +1,25 @@
"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
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>
) : (
+4 -4
View File
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getBookMeta } from "@/lib/api";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ book: string }> }
{ params }: { params: Promise<{ book: string }> },
) {
const { book } = await params;
const meta = await prisma.bookMeta.findUnique({ where: { book } });
const meta = await getBookMeta(book);
if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ book: meta.book, ...meta.data as Record<string, unknown> });
return NextResponse.json(meta);
}
+21 -37
View File
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import {
getSingleVerse,
getChapterVerseIndex,
getChapterVerses,
} from "@/lib/api";
export async function GET(
req: Request,
@@ -7,7 +11,7 @@ export async function GET(
) {
const { book } = await params;
const url = new URL(req.url);
const division = url.searchParams.get("division");
const division = url.searchParams.get("division") || null;
const chapterStr = url.searchParams.get("chapter");
const verseStr = url.searchParams.get("verse");
const indexOnly = url.searchParams.get("indexOnly") === "true";
@@ -22,20 +26,18 @@ export async function GET(
}
const chapter = parseInt(chapterStr);
const where: Record<string, unknown> = { book, chapter };
if (book === "bg") {
where.division = null;
} else if (division) {
where.division = division;
}
const effectiveDivision = book === "bg" ? null : division;
// --- Single verse mode: ?verse=N ---
if (verseStr) {
const verseNum = parseInt(verseStr);
const verse = await prisma.verse.findFirst({
where: { ...where, verse: verseNum },
});
const verse = await getSingleVerse(
book,
effectiveDivision,
chapter,
verseNum,
);
if (!verse) {
return NextResponse.json({ error: "Verse not found" }, { status: 404 });
@@ -46,15 +48,7 @@ export async function GET(
// --- Index-only mode: return lightweight verse list for the sidebar ---
if (indexOnly) {
const verses = await prisma.verse.findMany({
where,
orderBy: { verse: "asc" },
select: {
verse: true,
verseEnd: true,
reference: true,
},
});
const verses = await getChapterVerseIndex(book, effectiveDivision, chapter);
return NextResponse.json({ verses, total: verses.length });
}
@@ -62,24 +56,14 @@ export async function GET(
// --- Paginated mode: return full verse data in pages ---
const page = Math.max(1, parseInt(pageStr || "1"));
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
const skip = (page - 1) * limit;
const [verses, total] = await Promise.all([
prisma.verse.findMany({
where,
orderBy: { verse: "asc" },
skip,
take: limit,
}),
prisma.verse.count({ where }),
]);
return NextResponse.json({
verses,
total,
const result = await getChapterVerses(
book,
effectiveDivision,
chapter,
page,
limit,
totalPages: Math.ceil(total / limit),
hasMore: skip + verses.length < total,
});
);
return NextResponse.json(result);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getAllBooks } from "@/lib/api";
export async function GET() {
const metas = await prisma.bookMeta.findMany();
return NextResponse.json(metas.map((m) => ({ book: m.book, ...m.data as Record<string, unknown> })));
const books = await getAllBooks();
return NextResponse.json(books);
}
+2 -4
View File
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getAllGlossaryEntries } from "@/lib/api";
export async function GET() {
const entries = await prisma.glossaryEntry.findMany({
orderBy: { term: "asc" },
});
const entries = await getAllGlossaryEntries();
return NextResponse.json(entries);
}
+70
View File
@@ -0,0 +1,70 @@
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";
const exact = searchParams.get("exact") === "1" ? "1" : "";
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);
if (exact) url.searchParams.set("exact", "1");
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 },
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
// Same-origin proxy for the URL-shortener create endpoint. Client components
// (text-selection toolbar, share modal) call /api/share so the actual BFF
// request happens server-side — this is the only way to read `BFF_URL`,
// since Next.js does not expose non-`NEXT_PUBLIC_` env vars to the browser
// bundle. Same pattern as /api/search.
const BFF_BASE = process.env.BFF_URL || "http://localhost:8080";
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ success: false, error: { code: 400, message: "invalid JSON body" } },
{ status: 400 },
);
}
const url =
body && typeof body === "object" && "url" in body
? (body as { url: unknown }).url
: null;
if (typeof url !== "string" || !url.startsWith("/")) {
return NextResponse.json(
{
success: false,
error: { code: 400, message: "url must be a same-site path" },
},
{ status: 400 },
);
}
try {
const res = await fetch(new URL("/api/v1/share", BFF_BASE).toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ url }),
cache: "no-store",
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { "Content-Type": "application/json" },
});
} catch {
return NextResponse.json(
{ success: false, error: { code: 502, message: "BFF unreachable" } },
{ status: 502 },
);
}
}
-1
View File
@@ -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>
+1 -8
View File
@@ -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
+1 -1
View File
@@ -86,7 +86,7 @@ export function BooksGrid({ books }: { books: BookInfo[] }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search books..."
placeholder="Search books by name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
-2
View File
@@ -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>
+1 -8
View File
@@ -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 -1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
+1 -8
View File
@@ -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
-2
View File
@@ -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>
-1
View File
@@ -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>
+7 -4
View File
@@ -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}
-3
View File
@@ -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>
-2
View File
@@ -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>
+1 -8
View File
@@ -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
-2
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
+1 -8
View File
@@ -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
-1
View File
@@ -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>
-3
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
+1 -8
View File
@@ -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
-2
View File
@@ -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>
-1
View File
@@ -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>
-2
View File
@@ -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>
-2
View File
@@ -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>
+60 -25
View File
@@ -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
-1
View File
@@ -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>
+1 -8
View File
@@ -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
-2
View File
@@ -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>
+1 -8
View File
@@ -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
-2
View File
@@ -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>
);
}
+11 -27
View File
@@ -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");
}
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -91,7 +91,7 @@ export function EssaysGrid({ essays }: { essays: EssayInfo[] }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search essays..."
placeholder="Search essays by title..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
+7 -5
View File
@@ -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() {
+222 -1
View File
@@ -9,7 +9,21 @@
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
/*
* Font roles:
* --font-sans — Geist, the base UI/body font.
* --font-serif — Lora, used for verse text always, and surfaced
* through --font-title when reading-serif is on.
* --font-display — Bricolage Grotesque, the default for site titles.
* --font-title — the actual var components reference for titles;
* swaps between Bricolage and Lora based on the
* `reading-serif` class on <html>. The toggle rule
* itself lives outside @theme inline, below.
*/
--font-serif: var(--font-serif);
--font-display: var(--font-display);
--font-title: var(--font-display);
--font-heading: var(--font-display);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -48,6 +62,14 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
/* When the user enables "Serif Font" in the reading settings, page titles
swap from Bricolage Grotesque to Lora. Verse text (font-serif Tailwind
utility) is already Lora regardless of this toggle. */
:root.reading-serif {
--font-title: var(--font-serif);
--font-heading: var(--font-serif);
}
/* ── Light mode — "Aged Parchment" ── */
:root {
--background: oklch(0.957 0.012 79.8);
@@ -87,6 +109,107 @@
--reading-font-size: 1rem;
--reading-font-family: inherit;
--reading-text-align: start;
/* Post cards without an image use a solid background instead of a
gradient — crisp white on every light palette, palette-specific
darker contrast on dark palettes. */
--quote-card-bg: #ffffff;
/* Quote-card gradients — Aged Parchment (mahogany binding + temple gold).
Used by collection cards' fallback cover. Each palette below
overrides these so the gradient swaps with the active theme. */
--quote-gradient-1: linear-gradient(135deg, #3b2410 0%, #6b3e1f 45%, #c8902a 100%);
--quote-gradient-2: linear-gradient(135deg, #5c2918 0%, #a04a1c 50%, #d4a235 100%);
--quote-gradient-3: linear-gradient(135deg, #4a2e15 0%, #8a5a1f 60%, #d9bf80 100%);
--quote-gradient-4: linear-gradient(135deg, #4a1612 0%, #8b2c1a 50%, #c47525 100%);
}
/* ── 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);
--quote-card-bg: #ffffff;
/* Quote-card gradients — Tulasī Garden (forest depths + amber edge). */
--quote-gradient-1: linear-gradient(135deg, #102818 0%, #1f4d2f 45%, #b88a3c 100%);
--quote-gradient-2: linear-gradient(135deg, #1a2e1f 0%, #2d5a3a 50%, #9a8a4c 100%);
--quote-gradient-3: linear-gradient(135deg, #0c2e22 0%, #1d5f3e 60%, #d9c08a 100%);
--quote-gradient-4: linear-gradient(135deg, #0a1f15 0%, #1f3d2a 50%, #c8a880 100%);
}
/* ── 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);
--quote-card-bg: #ffffff;
/* Quote-card gradients — Yamunā Mist (river depths + dusk ochre). */
--quote-gradient-1: linear-gradient(135deg, #0a1e3c 0%, #1a3d6e 45%, #c89058 100%);
--quote-gradient-2: linear-gradient(135deg, #14213d 0%, #2a4d7a 50%, #c8bcb0 100%);
--quote-gradient-3: linear-gradient(135deg, #1a2a40 0%, #3a5878 50%, #c4805a 100%);
--quote-gradient-4: linear-gradient(135deg, #0e1f3c 0%, #28447a 60%, #d4c4b0 100%);
}
/* ── Dark mode — "Ancient Manuscript" ── */
@@ -122,6 +245,104 @@
--sidebar-accent-foreground: oklch(0.899 0.03 80.7);
--sidebar-border: oklch(0.777 0.059 84.4 / 15%);
--sidebar-ring: oklch(0.636 0.08 86.9);
/* Post-card flat bg — darker than --card so it reads as a distinct
surface even on the homepage where cards stack onto background. */
--quote-card-bg: oklch(0.13 0.018 86);
/* Quote-card gradients — Ancient Manuscript (midnight leather + saffron). */
--quote-gradient-1: linear-gradient(135deg, #0a0805 0%, #4a2f0e 45%, #d4a235 100%);
--quote-gradient-2: linear-gradient(135deg, #0c0a05 0%, #3e2614 50%, #c89028 100%);
--quote-gradient-3: linear-gradient(135deg, #14100a 0%, #4a3018 60%, #b87c2a 100%);
--quote-gradient-4: linear-gradient(135deg, #100c08 0%, #3a2010 50%, #c4762b 100%);
}
/* ── 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);
--quote-card-bg: oklch(0.1 0.05 252);
/* Quote-card gradients — Nīlamaṇi (Kṛṣṇa's sapphire hour). */
--quote-gradient-1: linear-gradient(135deg, #050a1c 0%, #14254a 45%, #5a8acc 100%);
--quote-gradient-2: linear-gradient(135deg, #0a1228 0%, #1e3458 50%, #b8c8dc 100%);
--quote-gradient-3: linear-gradient(135deg, #060c1e 0%, #1a2c4e 60%, #a8b8cc 100%);
--quote-gradient-4: linear-gradient(135deg, #080e22 0%, #1c3458 50%, #c8d4e8 100%);
}
/* ── 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);
--quote-card-bg: oklch(0.11 0.05 12);
/* Quote-card gradients — Vraja Twilight (deep wine + rose dusk). */
--quote-gradient-1: linear-gradient(135deg, #1c0a10 0%, #4a1a28 45%, #c4756a 100%);
--quote-gradient-2: linear-gradient(135deg, #1a0a14 0%, #3e1828 50%, #d4a08c 100%);
--quote-gradient-3: linear-gradient(135deg, #18080e 0%, #4a182a 60%, #e0a8a0 100%);
--quote-gradient-4: linear-gradient(135deg, #14080c 0%, #401628 50%, #c47868 100%);
}
@layer base {
+22 -2
View File
@@ -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
+53 -13
View File
@@ -1,13 +1,21 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
import { ogDefault } from "@/lib/og";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SiteHeader } from "@/components/site-header";
import { SiteSidebar } from "@/components/site-sidebar";
import { SiteBottomNav } from "@/components/site-bottom-nav";
import { SiteFooter } from "@/components/site-footer";
import { TextSelectionToolbar } from "@/components/text-selection-toolbar";
import { TextHighlighter } from "@/components/text-highlighter";
import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
import { AuthProvider } from "@/components/auth/auth-provider";
import { AuthGateProvider } from "@/components/auth/auth-gate";
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
import { PageBookmarks } from "@/components/social/page-bookmarks";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
const geistSans = Geist({
@@ -20,6 +28,26 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
// Editorial serif — used for verse text always, and for site titles when
// the user enables "Serif Font" in reading settings.
const lora = Lora({
variable: "--font-serif",
weight: ["400", "500", "600", "700"],
style: ["normal", "italic"],
subsets: ["latin", "latin-ext"],
display: "swap",
});
// Display sans — used for site titles when Serif Font is OFF (the default).
// Bricolage Grotesque has slight humanist warmth so it pairs well with the
// reverent aesthetic without feeling cold or generic.
const bricolage = Bricolage_Grotesque({
variable: "--font-display",
weight: ["400", "500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Prabhupāda Says",
description:
@@ -36,7 +64,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${lora.variable} ${bricolage.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<ThemeProvider
@@ -45,17 +73,29 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<AudioPlayerProvider>
<TooltipProvider>
{children}
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<SiteFooter />
</TooltipProvider>
</AudioPlayerProvider>
<AuthProvider>
<AuthGateProvider>
<AudioPlayerProvider>
<TooltipProvider>
<VerifyEmailBanner />
<SiteSidebar />
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
<SiteHeader />
{children}
<SiteFooter />
</div>
<SiteBottomNav />
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<PageBookmarks />
<Toaster />
</TooltipProvider>
</AudioPlayerProvider>
</AuthGateProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>
+1 -1
View File
@@ -82,7 +82,7 @@ export function OtherDocumentsGrid({
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search documents..."
placeholder="Search documents by title..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
+74 -270
View File
@@ -1,280 +1,84 @@
"use client";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/page-header";
import { Logo } from "@/components/logo";
import {
BookOpen,
BookA,
PenLine,
FileStack,
NotebookPen,
Newspaper,
Mail,
Users,
Mic,
} from "lucide-react";
import { Compass, LogIn, Sparkles } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
/**
* / — the Home (Feed) gate.
*
* The Instagram-inspired infinite-scroll feed of posts from people you
* follow will live here. Until that ships we show two paths:
*
* • Sign in — for visitors who don't have an account yet; the eventual
* feed is personalised, so an account is the prerequisite.
* • Explore the Knowledgebase — Śrīla Prabhupāda's full library is
* ready today, regardless of feed status.
*
* The "Sign in" CTA hides once the user is authenticated — at that point
* there's nothing to gate, just a "feed coming soon" note above the
* always-on Knowledgebase entry.
*/
export default function HomePage() {
const { user, loading } = useAuth();
const authed = !loading && !!user;
export default function Home() {
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Logo height={56} className="mb-1" />
</PageHeader>
<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"
>
<Sparkles className="size-6" />
</span>
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Structured Books (JSON) */}
<Link href="/books" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<BookOpen className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">Books</CardTitle>
<CardDescription>
Structured verse-by-verse editions
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and
other books with Devanagari, Bengali, transliteration,
synonyms, translations &amp; purports.
</p>
</CardContent>
</Card>
</Link>
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight text-balance">
{authed
? "Your feed is coming soon"
: "Welcome to Prabhupāda Says"}
</h1>
{/* Essays */}
<Link href="/essays" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<PenLine className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">Essays</CardTitle>
<CardDescription>
Essays by Śrīla Prabhupāda
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Foundational essays including &ldquo;Krishna The Reservoir Of
Pleasure&rdquo; and &ldquo;Who Is Crazy?&rdquo; — timeless
introductions to the science of Kṛṣṇa consciousness.
</p>
</CardContent>
</Card>
</Link>
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
{authed
? "We're building a feed of quotes shared by the community. Until it lands, dive into the Knowledgebase — Śrīla Prabhupāda's books, letters, lectures, and teachings."
: "Sign in to follow seekers and see their quotes here once the feed launches, or jump straight into the Knowledgebase — the original, unedited writings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda."}
</p>
{/* Magazines */}
<Link href="/magazines" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<Newspaper className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Magazines
</CardTitle>
<CardDescription>Back to Godhead magazines</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
The original Back to Godhead magazine founded by Śrīla
Prabhupāda with articles on Kṛṣṇa consciousness, Vedic
philosophy, and spiritual life.
</p>
</CardContent>
</Card>
</Link>
{/* Letters */}
<Link href="/letters" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<Mail className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">Letters</CardTitle>
<CardDescription>
6,800+ letters (1947–1977)
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Śrīla Prabhupāda&apos;s personal correspondence — over 6,800
letters to disciples, Godbrothers, government officials, and
well-wishers. Browse by year, recipient, or location.
</p>
</CardContent>
</Card>
</Link>
{/* Transcriptions */}
<Link href="/transcriptions" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<Mic className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Transcriptions
</CardTitle>
<CardDescription>
Lectures &amp; conversations
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Transcriptions of Śrīla Prabhupāda&apos;s lectures, morning
walks, room conversations, and dialogues — the living voice of
the Founder-Ācārya preserved in text.
</p>
</CardContent>
</Card>
</Link>
{/* Disciples */}
<Link href="/disciples" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<Users className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Disciples
</CardTitle>
<CardDescription>
4,700+ initiated disciples
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
A directory of disciples initiated by Śrīla Prabhupāda between
1966 and 1977 — browse by name, initiation place, or year.
</p>
</CardContent>
</Card>
</Link>
{/* Journals */}
<Link href="/journals" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<NotebookPen className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Journals
</CardTitle>
<CardDescription>
Personal diaries &amp; journals
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Śrīla Prabhupāda&apos;s personal diaries — including the
historic Jaladuta Diary from his 1965 voyage to America and
The Beginning, his 1966 New York journal documenting the
founding of the Hare Kṛṣṇa movement.
</p>
</CardContent>
</Card>
</Link>
{/* Glossary */}
<Link href="/glossary" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<BookA className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Glossary
</CardTitle>
<CardDescription>
2,700+ Sanskrit &amp; Vedic terms
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
A comprehensive, searchable glossary of Sanskrit and Vedic
terms from Śrīla Prabhupāda&apos;s books — definitions,
cross-references, and encyclopedic entries.
</p>
</CardContent>
</Card>
</Link>
{/* Other Documents */}
<Link href="/other-documents" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<FileStack className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Other Documents
</CardTitle>
<CardDescription>
Miscellaneous analyses &amp; writings
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Miscellaneous documents, analyses, and writings by Śrīla
Prabhupāda that don&apos;t fit neatly into the books or essays
collections.
</p>
</CardContent>
</Card>
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
{!authed ? (
<Link
href="/login"
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]"
>
<LogIn className="size-4" />
Sign in to view feed
</Link>
) : null}
<Link
href="/search"
className={
authed
? "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]"
: "inline-flex items-center justify-center gap-2 rounded-lg border border-border text-foreground px-5 h-11 text-sm font-medium hover:bg-muted transition-colors min-w-[12rem]"
}
>
<Compass className="size-4" />
Explore Knowledgebase
</Link>
</div>
</main>
</div>
{!authed ? (
<p className="text-xs text-muted-foreground mt-1">
No account?{" "}
<Link href="/signup" className="text-foreground hover:underline">
Create one
</Link>{" "}
— it&apos;s free.
</p>
) : null}
</div>
</main>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { buildOgMeta } from "@/lib/og";
import { SearchContent } from "./search-content";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Search",
detail: "Search across all content",
quote:
"Search across Śrīla Prabhupāda's books, letters, lectures, conversations, and more.",
});
}
export default function SearchPage() {
return <SearchContent />;
}
+706
View File
@@ -0,0 +1,706 @@
"use client";
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { buildSearchResultUrl } from "@/lib/text-highlight";
import { KnowledgebaseIndex } from "@/components/knowledgebase-index";
import {
Search,
Loader2,
BookOpen,
Mail,
Mic,
BookA,
PenLine,
NotebookPen,
Newspaper,
FileStack,
ChevronLeft,
ChevronRight,
WholeWord,
} 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 initialExact = searchParams.get("exact") === "1";
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);
// When `exactMatch` is on, the BFF disables typo tolerance + prefix expansion.
// Toggling it resets pagination (handled below) and resyncs the URL.
const [exactMatch, setExactMatch] = useState(initialExact);
// 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, exact: boolean) => {
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));
if (exact) params.set("exact", "1");
const qs = params.toString();
router.replace(`/search${qs ? `?${qs}` : ""}`, { scroll: false });
},
[router],
);
// Reset page to 1 when query, type, or exact-mode 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);
const [prevExact, setPrevExact] = useState(exactMatch);
if (
prevQuery !== debouncedQuery ||
prevType !== activeTypeParam ||
prevExact !== exactMatch
) {
setPrevQuery(debouncedQuery);
setPrevType(activeTypeParam);
setPrevExact(exactMatch);
setCurrentPage(1);
}
// Fetch search results
useEffect(() => {
const query = debouncedQuery.trim();
if (!query) {
pushUrl("", urlTypeParam, 1, exactMatch);
return;
}
setIsLoading(true);
pushUrl(query, urlTypeParam, currentPage, exactMatch);
const controller = new AbortController();
const params = new URLSearchParams({
q: query,
type: activeTypeParam,
page: String(currentPage),
limit: "20",
});
if (exactMatch) params.set("exact", "1");
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, exactMatch, 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="flex-1">
{/* Editorial hero — title in serif, supporting copy underneath */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 pt-10 sm:pt-16 pb-6">
<p className="uppercase tracking-[0.22em] text-xs font-semibold text-muted-foreground">
Search
</p>
<h1 className="mt-4 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-4xl leading-tight text-foreground">
Find a word, a verse, a name.
</h1>
<p className="mt-3 max-w-xl text-base text-muted-foreground leading-relaxed">
Smart search across books, letters, lectures, essays,
journals and magazines. Diacritics are ignored, so
&ldquo;Krsna&rdquo; finds &ldquo;Kṛṣṇa.&rdquo;
</p>
</section>
<main className="max-w-5xl mx-auto px-4 sm:px-6 pb-16">
{/* Search input — the right edge carries the loading spinner (when
fetching) and the exact-match toggle button. Right padding on the
input reserves space for both so text never collides with them. */}
<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="Try “sarva dharman parityajya” or “letter to mukunda”…"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="h-14 pl-12 pr-24 text-lg rounded-xl ring-1 ring-foreground/10 border-transparent shadow-sm focus-visible:ring-2 focus-visible:ring-primary/40"
/>
{isLoading && (
<Loader2 className="absolute right-14 top-1/2 -translate-y-1/2 size-5 text-muted-foreground animate-spin" />
)}
<button
type="button"
onClick={() => setExactMatch((v) => !v)}
aria-pressed={exactMatch}
aria-label={
exactMatch
? "Exact match is on — only literal keyword matches"
: "Enable exact match — disables typo tolerance"
}
title={
exactMatch
? "Exact match: on (no typo tolerance, no partial words)"
: "Exact match: off — toggle to require literal keyword matches"
}
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-lg transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-primary/40",
exactMatch
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<WholeWord className="size-5" />
</button>
</div>
{/* Content Type Tabs */}
<div className="mt-5 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 — render the full Knowledgebase index so the search
page doubles as the browsing entry point. */}
{!isLoading && !hasSearched && <KnowledgebaseIndex />}
{/* No results */}
{!isLoading &&
hasSearched &&
effectiveData &&
effectiveData.results.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Search className="size-12 text-muted-foreground/40 mb-4" />
<p className="text-lg text-muted-foreground">
No results found for &ldquo;{debouncedQuery}&rdquo;.
<br />
<span className="text-sm">
Try a different search term or content type.
</span>
</p>
</div>
)}
{/* Results */}
{!isLoading && effectiveData && effectiveData.results.length > 0 && (
<>
{/* Results count + top pagination */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
<p className="text-sm text-muted-foreground">
{effectiveData.total.toLocaleString()}{" "}
{effectiveData.total === 1 ? "result " : "results "} for
&ldquo;
{effectiveData.query}&rdquo;
</p>
{effectiveData.total_pages > 1 && (
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
onPageChange={handlePageChange}
compact
/>
)}
</div>
{/* Result list */}
<div className="space-y-3">
{effectiveData.results.map((result, i) => (
<ResultCard
key={`${result.collection}-${result.id || i}`}
result={result}
/>
))}
</div>
{/* Bottom pagination */}
{effectiveData.total_pages > 1 && (
<div className="mt-8">
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
total={effectiveData.total}
onPageChange={handlePageChange}
/>
</div>
)}
</>
)}
</div>
</main>
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination Controls
// ---------------------------------------------------------------------------
function PaginationControls({
page,
totalPages,
total,
onPageChange,
compact = false,
}: {
page: number;
totalPages: number;
total?: number;
onPageChange: (page: number) => void;
compact?: boolean;
}) {
if (compact) {
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</Button>
</div>
);
}
return (
<div className="flex items-center justify-between">
<Button
variant="outline"
size="default"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{total != null && ` · ${total.toLocaleString()} results`}
</span>
<Button
variant="outline"
size="default"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Result Card
// ---------------------------------------------------------------------------
function ResultCard({ result }: { result: SearchResult }) {
const config = COLLECTION_CONFIG[result.collection] || {
label: result.collection,
icon: FileStack,
color: "bg-muted text-muted-foreground",
};
const Icon = config.icon;
// Append `?hl=<matched-text>` so the destination page highlights the snippet
// text Typesense matched. Falls back to the original URL if no marks were
// present in the snippet.
const href = buildSearchResultUrl(result.url, result.snippet);
return (
<Link
href={href}
className="block group rounded-xl bg-card/60 ring-1 ring-foreground/8 hover:ring-foreground/25 hover:bg-card transition-all px-4 sm:px-5 py-4"
onClick={() => {
try {
sessionStorage.setItem("spsays_from_search", "1");
} catch {
/* sessionStorage unavailable */
}
}}
>
<div 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-[0.65rem] uppercase tracking-[0.12em] font-medium",
config.color,
)}
>
<Icon className="size-3" />
{config.label}
</span>
</div>
{/* Title — serif for editorial weight */}
<p className="font-[family-name:var(--font-title)] font-semibold text-lg 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-primary/20 [&_mark]:text-foreground [&_mark]:rounded-sm [&_mark]:px-0.5 dark:[&_mark]:bg-primary/30"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
{/* Meta information */}
<ResultMeta result={result} />
</div>
</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="flex-1 max-w-5xl mx-auto px-4 sm:px-6 py-16 flex items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
}
>
<SearchInner />
</Suspense>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
export default function ShareNotFound() {
return (
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
<h1 className="text-2xl font-semibold">Link not found</h1>
<p className="text-muted-foreground mt-3 text-sm">
This short link may have expired or never existed.
</p>
<Link
href="/"
className="text-primary mt-6 text-sm underline underline-offset-4"
>
Return to Prabhupāda Says →
</Link>
</main>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { resolveShortLink } from "@/lib/share";
import { buildShareOgMeta } from "@/lib/share-og";
import { ogDefault } from "@/lib/og";
import { RedirectClient } from "./redirect-client";
type PageProps = { params: Promise<{ code: string }> };
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { code } = await params;
const fullPath = await resolveShortLink(code);
if (!fullPath) {
return {
title: "Link expired — Prabhupāda Says",
robots: { index: false, follow: false },
...ogDefault(),
};
}
return buildShareOgMeta(fullPath);
}
export default async function SharePage({ params }: PageProps) {
const { code } = await params;
const fullPath = await resolveShortLink(code);
if (!fullPath) notFound();
return (
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
<p className="text-muted-foreground text-sm">
Redirecting to your highlight…
</p>
<noscript>
<p className="mt-4 text-sm">
JavaScript is disabled.{" "}
<a className="underline" href={fullPath}>
Continue to the page
</a>
.
</p>
</noscript>
<RedirectClient to={fullPath} />
</main>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
// Client-side redirect: we deliberately don't use `redirect()` from
// next/navigation on the server, because that returns a 307 before the OG
// `<head>` is ever served. With `generateMetadata` + a 200 body, social-link
// scrapers get the full OG card while real users still get redirected.
export function RedirectClient({ to }: { to: string }) {
const router = useRouter();
useEffect(() => {
router.replace(to);
}, [router, to]);
return null;
}
+40 -16
View File
@@ -7,7 +7,7 @@ import {
getTranscription,
getAdjacentTranscriptions,
} from "@/lib/transcriptions";
import { buildOgMeta } from "@/lib/og";
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -18,25 +18,49 @@ import { PlayAudioButton } from "./play-audio-button";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { slug } = await params;
const sp = await searchParams;
const t = await getTranscription(slug);
if (!t) return {};
const detail = [t.date, t.place].filter(Boolean).join(" · ");
return buildOgMeta({
title: t.name,
detail: detail || undefined,
quote:
t.content.length > 0
? t.content
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
?.text?.replace(/<[^>]+>/g, "")
.slice(0, 200)
: undefined,
// Build paragraphs from content blocks for hl matching
const paragraphs = t.content.map((b) => {
const speakerPrefix = b.speaker ? `${b.speaker}: ` : "";
return speakerPrefix + b.text.replace(/<[^>]+>/g, "");
});
const hlQuote = extractQuoteFromHl(sp, paragraphs);
if (hlQuote) {
return buildOgMeta({
title: `Transcripts: ${t.name}`,
detail: detail || undefined,
quote: hlQuote,
});
}
// Fallback: find a good Prabhupāda quote from the content
const spQuote = t.content
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
?.text?.replace(/<[^>]+>/g, "")
.slice(0, 200);
if (spQuote) {
return buildOgMeta({
title: t.name,
detail: detail || undefined,
quote: spQuote,
});
}
return ogForTranscription(`Transcripts: ${t.name}`, detail || undefined);
}
export default async function TranscriptionPage({
@@ -93,16 +117,16 @@ export default async function TranscriptionPage({
<TranscriptionNav prev={prev} next={next} />
{/* Audio + Participants row */}
<div className="w-full flex flex-col md:flex-row items-start md:items-end justify-between gap-4">
<div className="w-full flex flex-col md:flex-row items-start md:items-start justify-between gap-4">
{/* Participants */}
{t.participants.length > 0 && (
<>
<div className="flex flex-col gap-2">
<div className="flex pl-2 items-center gap-2 flex-wrap text-sm text-muted-foreground">
<div className="">
<div className="flex flex-row pl-2 mb-2 items-center gap-2 text-sm text-muted-foreground">
<Users className="size-3.5" />
<span>Participants:</span>
</div>
<div className="flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
<div className="max-w-full flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
{t.participants.map((p, i) => (
<Badge
key={i}
@@ -119,7 +143,7 @@ export default async function TranscriptionPage({
{/* Play Audio button */}
{t.audioUrl && (
<div className="w-full md:w-auto flex justify-end">
<div className="w-full md:w-[25%] flex justify-end">
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
@@ -1,8 +1,12 @@
"use client";
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player";
import {
useAudioPlayer,
type AudioTrack,
AudioWaveAnimation,
} from "@/components/audio-player";
import { cn } from "@/lib/utils";
import { Play, Pause, Radio } from "lucide-react";
import { Play, Pause } from "lucide-react";
type PlayAudioButtonProps = {
audioUrl: string;
@@ -63,7 +67,11 @@ export function PlayAudioButton({
aria-label={isThisPlaying ? "Pause" : "Play audio"}
>
{isThisPlaying ? (
<Pause className="size-3.5" />
<AudioWaveAnimation
paused={false}
colorClassName="bg-primary-foreground"
size="sm"
/>
) : (
<Play className="size-3.5 ml-0.5" />
)}
@@ -85,7 +93,11 @@ export function PlayAudioButton({
>
{isThisPlaying ? (
<>
<Radio className="size-4 animate-pulse" />
<AudioWaveAnimation
paused={false}
colorClassName="bg-primary-foreground"
size="sm"
/>
<span>Now Playing</span>
</>
) : (
+2 -7
View File
@@ -3,7 +3,7 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og";
import { ogForTranscriptions } from "@/lib/og";
import {
getTranscriptionYears,
getTranscriptionPlaces,
@@ -14,12 +14,7 @@ import { TranscriptionsNav } from "./transcriptions-nav";
import { TranscriptionsBrowser } from "./transcriptions-browser";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Transcriptions",
detail: "3,700+ lectures & conversations (1966–1977)",
quote:
"Transcriptions of Śrīla Prabhupāda's lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text.",
});
return ogForTranscriptions();
}
export default async function TranscriptionsPage({
+13 -2
View File
@@ -8,7 +8,8 @@ import { buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Clock } from "lucide-react";
import { Clock, MapPin } from "lucide-react";
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
export async function generateMetadata({
params,
@@ -60,7 +61,7 @@ export default async function TranscriptionsByPlacePage({
<Link
key={t.slug}
href={`/transcriptions/${t.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
@@ -87,6 +88,16 @@ export default async function TranscriptionsByPlacePage({
)}
</div>
</div>
{t.audioUrl && (
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
variant="compact"
/>
)}
</Link>
))}
</CardContent>
@@ -8,6 +8,7 @@ import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
import {
Drawer,
DrawerClose,
@@ -34,6 +35,7 @@ export type TranscriptionItem = {
reference: string | null;
durationMinutes: number | null;
participants: string[];
audioUrl: string | null;
};
type YearGroup = {
@@ -364,7 +366,7 @@ function MonthSection({
<li key={t.slug}>
<Link
href={`/transcriptions/${t.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 pl-12 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
className="flex items-center px-5 pl-12 py-3.5 gap-3 hover:bg-muted transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
@@ -396,6 +398,16 @@ function MonthSection({
)}
</div>
</div>
{t.audioUrl && (
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
variant="compact"
/>
)}
</Link>
</li>
))}
+2 -2
View File
@@ -17,8 +17,8 @@ export async function generateMetadata({
const transcriptions = await getTranscriptionsByYear(year);
return buildOgMeta({
title: "Transcriptions",
detail: `${transcriptions.length} transcriptions from ${year}`,
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year}.`,
detail: `${transcriptions.length} lectures & conversations from ${year}`,
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year} — the living voice of the Founder-Ācārya preserved in text.`,
});
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
/**
* Public profile at /u/[handle].
*
* Anonymous-friendly. Mirrors /u/me's shell — ProfileHeader at the top,
* a smaller tab strip (Posts + Collections only; Bookmarks and Mentions
* are private), then a grid of the active tab's content.
*
* Owner-handle redirect: when the logged-in user lands on their own
* /u/<handle>, we send them to /u/me where the owner controls live.
*/
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { notFound, useRouter } from "next/navigation";
import { FolderClosed, LayoutGrid } from "lucide-react";
import {
profile,
SocialError,
type Post,
type PublicProfile,
} from "@/lib/social";
import { ProfileHeader } from "@/components/profile/profile-header";
import {
ProfileTabs,
type ProfileTab,
} from "@/components/profile/profile-tabs";
import { PostViews } from "@/components/social/post-views";
import { CollectionViews } from "@/components/social/collection-views";
import { useAuth } from "@/components/auth/auth-provider";
type Tab = "posts" | "collections";
export default function PublicProfilePage(props: {
params: Promise<{ handle: string }>;
searchParams?: Promise<{ tab?: string }>;
}) {
const { handle } = use(props.params);
const search = props.searchParams ? use(props.searchParams) : undefined;
const tab: Tab = search?.tab === "collections" ? "collections" : "posts";
const router = useRouter();
const { user } = useAuth();
const [data, setData] = useState<PublicProfile | null>(null);
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [notFoundState, setNotFound] = useState(false);
const isOwn = !!user && user.handle.toLowerCase() === handle.toLowerCase();
useEffect(() => {
if (isOwn) router.replace("/u/me");
}, [isOwn, router]);
useEffect(() => {
if (isOwn) return;
let cancelled = false;
setLoading(true);
Promise.all([profile.get(handle), profile.feed(handle, 30)])
.then(([p, posts]) => {
if (cancelled) return;
setData(p);
setFeed(posts ?? []);
})
.catch((err) => {
if (err instanceof SocialError && err.status === 404) {
setNotFound(true);
}
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [handle, isOwn]);
if (notFoundState) notFound();
if (isOwn) return null;
if (loading || !data) {
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 py-12">
<p className="text-sm text-muted-foreground">Loading profile…</p>
</main>
);
}
const tabs: ProfileTab[] = [
{
href: `/u/${data.handle}`,
label: "Posts",
icon: <LayoutGrid className="size-5" />,
},
{
href: `/u/${data.handle}?tab=collections`,
label: "Collections",
icon: <FolderClosed className="size-5" />,
},
];
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
<ProfileHeader
avatarURL={data.avatar_url}
name={data.name}
handle={data.handle}
postCount={data.post_count}
joinedAt={data.joined_at}
/>
<ProfileTabs tabs={tabs} />
<div className="py-6">
{tab === "collections" ? (
data.public_collections && data.public_collections.length > 0 ? (
<CollectionViews
collections={data.public_collections}
hrefFor={(c) => `/c/${c.id}`}
/>
) : (
<p className="text-sm text-muted-foreground">
No public collections yet.
</p>
)
) : (
<PostViews
posts={feed}
hrefFor={(p) => `/u/${data.handle}/posts#post-${p.id}`}
emptyMessage="No posts yet — quotes shared by this user will appear here."
/>
)}
</div>
</main>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
/**
* /u/[handle]/posts — public Instagram-style scroll feed.
*
* Mirrors /u/me/posts but read-only (no Delete / Update background). The
* grid thumbnail on /u/[handle] links here with a `#post-<id>` hash so
* the feed scrolls to the tapped post on mount.
*/
import { use, useEffect, useRef, useState } from "react";
import { notFound } from "next/navigation";
import { profile, SocialError, type Post } from "@/lib/social";
import { PostScrollCard } from "@/components/social/post-scroll-card";
export default function PublicPostsFeedPage(props: {
params: Promise<{ handle: string }>;
}) {
const { handle } = use(props.params);
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [missing, setMissing] = useState(false);
const scrolledToHashRef = useRef(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
profile
.feed(handle, 60)
.then((rows) => {
if (cancelled) return;
setFeed(rows ?? []);
})
.catch((err) => {
if (err instanceof SocialError && err.status === 404) {
setMissing(true);
}
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [handle]);
useEffect(() => {
if (loading || feed.length === 0 || scrolledToHashRef.current) return;
if (typeof window === "undefined") return;
const hash = window.location.hash;
if (!hash) return;
scrolledToHashRef.current = true;
requestAnimationFrame(() => {
const el = document.getElementById(hash.slice(1));
if (el) el.scrollIntoView({ block: "start", behavior: "auto" });
});
}, [loading, feed.length]);
if (missing) notFound();
return (
<main className="flex-1 max-w-xl w-full mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-6">
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : feed.length === 0 ? (
<p className="text-sm text-muted-foreground">No posts yet.</p>
) : (
feed.map((p) => <PostScrollCard key={p.id} post={p} />)
)}
</main>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Trash2, ExternalLink } from "lucide-react";
import { bookmarks, SocialError, type Bookmark } from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
import { displaySourceLabel } from "@/lib/source-reference";
const FREE_LIMIT = 1;
const PREMIUM_LIMIT = 5;
export default function MyBookmarksPage() {
const { user } = useAuth();
const [list, setList] = useState<Bookmark[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
bookmarks
.list()
.then((r) => setList(r ?? []))
.catch((err) =>
setError(
err instanceof SocialError ? err.message : "Could not load bookmarks.",
),
)
.finally(() => setLoading(false));
}, []);
async function remove(id: string) {
const prev = list;
setList((rows) => rows.filter((b) => b.id !== id));
try {
await bookmarks.delete(id);
} catch (err) {
setList(prev);
setError(err instanceof SocialError ? err.message : "Could not delete.");
}
}
const limit = user?.role === "admin" || user?.plan === "premium" ? PREMIUM_LIMIT : FREE_LIMIT;
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
return (
<div className="space-y-5">
<div className="rounded-lg bg-card/60 ring-1 ring-foreground/8 px-4 py-3 text-sm flex items-center justify-between">
<span className="text-muted-foreground">
Bookmarks used:{" "}
<span className="text-foreground tabular-nums">
{list.length} / {limit}
</span>
</span>
{user?.plan !== "premium" && list.length >= limit ? (
<span className="text-xs text-amber-600 dark:text-amber-400">
Upgrade to premium for 5 bookmarks
</span>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">
No bookmarks yet. Select text anywhere and use “Add bookmark” in the
more menu.
</p>
) : (
<ul className="space-y-3">
{list.map((b) => {
// If the stored URL doesn't already carry a `?hl=` (old data, or
// a bookmark saved without a selection), synthesise one from the
// excerpt so clicking still scrolls to + highlights the passage.
const href =
hasHighlight(b.source_url) || !b.excerpt
? b.source_url
: appendHighlight(b.source_url, b.excerpt);
return (
<li
key={b.id}
className="rounded-xl bg-card ring-1 ring-foreground/10 p-4 flex items-start gap-3"
>
<div className="flex-1 min-w-0">
<Link
href={href}
className="font-[family-name:var(--font-title)] text-base text-foreground hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="size-3.5" />
{displaySourceLabel(b.source_url, b.source_title)}
</Link>
{b.excerpt ? (
<p className="mt-1 text-sm text-muted-foreground italic line-clamp-2">
“{b.excerpt}”
</p>
) : null}
<p className="text-xs text-muted-foreground mt-2">
Saved {new Date(b.created_at).toLocaleDateString()}
</p>
</div>
<button
onClick={() => remove(b.id)}
aria-label="Delete bookmark"
className="size-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="size-4" />
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
/**
* /u/me/collections/[id] — single-collection view.
*
* The whole page is the collection's CollectionScrollCard (carousel of
* cover + posts) and an Edit button below it. All editing — title,
* description, cover, member posts, delete — lives in the EditCollectionDrawer
* so the page itself stays focused on the carousel preview.
*/
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import {
collections,
SocialError,
type Collection,
type Post,
} from "@/lib/social";
import { CollectionScrollCard } from "@/components/social/collection-scroll-card";
import { EditCollectionDrawer } from "@/components/social/edit-collection-drawer";
export default function CollectionDetailPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = use(props.params);
const router = useRouter();
const [c, setC] = useState<Collection | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
if (cancelled) return;
setC(collection);
setPosts(posts ?? []);
})
.catch((err) => {
setError(
err instanceof SocialError ? err.message : "Could not load collection.",
);
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [id]);
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
if (error && !c) return <p className="text-sm text-destructive">{error}</p>;
if (!c) return null;
return (
<div className="space-y-6">
<button
type="button"
onClick={() => router.push("/u/me/collections")}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" /> All collections
</button>
<CollectionScrollCard
collection={c}
href={`/u/me/collections/${c.id}`}
initialPosts={posts}
onEdit={() => setEditOpen(true)}
/>
<EditCollectionDrawer
open={editOpen}
onClose={() => setEditOpen(false)}
collection={c}
currentPostIds={posts.map((p) => p.id)}
onSaved={({ collection, posts: rows }) => {
setC(collection);
setPosts(rows);
}}
onDeleted={() => router.push("/u/me/collections")}
/>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { collections, SocialError, type Collection } from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { CollectionViews } from "@/components/social/collection-views";
export default function MyCollectionsPage() {
const { user } = useAuth();
const [list, setList] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Inline create form
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(true);
const [creating, setCreating] = useState(false);
useEffect(() => {
setLoading(true);
collections
.mine()
.then((r) => setList(r ?? []))
.catch((err) =>
setError(
err instanceof SocialError ? err.message : "Could not load collections.",
),
)
.finally(() => setLoading(false));
}, []);
async function createOne(e: React.SyntheticEvent) {
e.preventDefault();
if (!title.trim()) return;
setCreating(true);
setError(null);
try {
const c = await collections.create({
title,
description: description || undefined,
is_public: isPublic,
});
setList((rows) => [c, ...rows]);
setTitle("");
setDescription("");
setShowCreate(false);
} catch (err) {
setError(err instanceof SocialError ? err.message : "Could not create.");
} finally {
setCreating(false);
}
}
const isPremium = user?.plan === "premium";
const publicCount = list.filter((c) => c.is_public).length;
const privateCount = list.filter((c) => !c.is_public).length;
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{isPremium ? (
<>Premium — unlimited collections.</>
) : (
<>
<span className="tabular-nums">{publicCount}</span> / 5 public ·{" "}
<span className="tabular-nums">{privateCount}</span> / 5 private
</>
)}
</p>
{!showCreate ? (
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary text-primary-foreground px-3 h-10 text-sm font-medium"
>
<Plus className="size-4" />
New
</button>
) : null}
</div>
{showCreate ? (
<form
onSubmit={createOne}
className="rounded-xl bg-card ring-1 ring-foreground/10 p-4 space-y-3"
>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Collection title"
maxLength={120}
required
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
/>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none"
/>
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
/>
<span className="text-foreground">Public</span>
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setShowCreate(false);
setTitle("");
}}
className="px-3 h-9 rounded-md text-muted-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="submit"
disabled={creating || !title.trim()}
className="px-3 h-9 rounded-md bg-primary text-primary-foreground disabled:opacity-60"
>
{creating ? "Creating…" : "Create"}
</button>
</div>
</div>
</form>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<CollectionViews
collections={list}
hrefFor={(c) => `/u/me/collections/${c.id}`}
emptyMessage="No collections yet — create one above, or save a quote into a new collection from the selection toolbar."
/>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
/**
* /u/me shared layout — auth gate + Instagram-style profile shell.
*
* • Anonymous visitors trigger the global AuthModal and are bounced
* home if they dismiss it (unchanged from before).
* • Renders the ProfileHeader + ProfileTabs above every /u/me/* page.
* Tabs: Posts / Collections / Bookmarks / Mentions.
*
* The old left ProfileSidebar is gone. The "Edit profile" affordance
* moved onto the header as a button that toggles edit mode on /u/me
* via a query param (kept simple — no new route for the form).
*/
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import {
AtSign,
Bookmark,
FolderClosed,
LayoutGrid,
} from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { ProfileHeader } from "@/components/profile/profile-header";
import { ProfileTabs, type ProfileTab } from "@/components/profile/profile-tabs";
export default function MeLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
if (loading || !user) {
return (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">
{loading ? "Loading…" : "Sign in to view your library."}
</p>
</main>
);
}
const tabs: ProfileTab[] = [
{
href: "/u/me",
label: "Posts",
icon: <LayoutGrid className="size-5" />,
activeFor: ["/u/me/posts"],
},
{
href: "/u/me/collections",
label: "Collections",
icon: <FolderClosed className="size-5" />,
},
{
href: "/u/me/bookmarks",
label: "Bookmarks",
icon: <Bookmark className="size-5" />,
},
{
href: "/u/me/mentions",
label: "Mentions",
icon: <AtSign className="size-5" />,
},
];
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
<ProfileHeader
avatarURL={user.avatar_url}
name={user.name}
handle={user.handle}
joinedAt={user.created_at}
editHref="/u/me?edit=1"
/>
<ProfileTabs tabs={tabs} />
<div className="py-6">{children}</div>
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
/**
* /u/me/mentions — placeholder until the Mentions feature ships.
*
* The tab itself is wired into the profile shell already so users see
* an empty state instead of a 404 when they tap it.
*/
import { AtSign } from "lucide-react";
export default function MentionsPage() {
return (
<div className="flex flex-col items-center text-center gap-3 py-12 text-muted-foreground">
<AtSign className="size-8" />
<p className="text-sm">
Mentions of you across posts will appear here. Coming soon.
</p>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
/**
* /u/me — owner Posts grid (the default tab).
*
* Grid-only — no Grid/Scroll toggle. Tapping a thumbnail jumps to
* /u/me/posts#post-<id> where the Instagram-style scroll feed renders
* the full passages.
*
* `?edit=1` (set by the header's Edit profile button) swaps the grid
* for the inline edit form. Saving returns to /u/me. Keeps the URL
* shareable while avoiding a separate route + layout.
*/
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { LayoutGrid } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { ProfileEditForm } from "@/components/profile/profile-edit-form";
import { PostGridCard } from "@/components/social/post-grid-card";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { posts, SocialError, type Post } from "@/lib/social";
export default function MyProfilePage() {
const { user } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const editMode = searchParams.get("edit") === "1";
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Post id pending confirmation — modal opens when non-null and runs
// the actual delete on confirm.
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
useEffect(() => {
if (!user) return;
setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
.catch((err) =>
setError(
err instanceof SocialError ? err.message : "Could not load posts.",
),
)
.finally(() => setLoading(false));
}, [user]);
// Card delete buttons stage an id; ConfirmModal runs `performDelete`
// when the user confirms. We throw on failure so the modal stays open
// with an inline error message.
async function performDelete() {
const id = pendingDelete;
if (!id) return;
const prev = feed;
setFeed((rows) => rows.filter((p) => p.id !== id));
try {
await posts.delete(id);
} catch (err) {
setFeed(prev);
const msg =
err instanceof SocialError ? err.message : "Could not delete.";
setError(msg);
throw new Error(msg);
}
}
function onPostUpdated(updated: Post) {
setFeed((rows) => rows.map((p) => (p.id === updated.id ? updated : p)));
}
if (!user) return null;
if (editMode) {
return <ProfileEditForm user={user} onDone={() => router.replace("/u/me")} />;
}
return (
<section>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : feed.length === 0 ? (
<EmptyPosts />
) : (
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{feed.map((p) => (
<PostGridCard
key={p.id}
post={p}
onDelete={(id) => setPendingDelete(id)}
onUpdated={onPostUpdated}
href={`/u/me/posts#post-${p.id}`}
/>
))}
</div>
)}
<ConfirmModal
open={!!pendingDelete}
onClose={() => setPendingDelete(null)}
title="Delete this post?"
message="The post will be removed permanently from your feed and from any collections it's in. This can't be undone."
confirmLabel="Delete post"
onConfirm={performDelete}
/>
</section>
);
}
function EmptyPosts() {
return (
<div className="flex flex-col items-center text-center gap-3 py-12 text-muted-foreground">
<LayoutGrid className="size-8" />
<p className="text-sm">
You haven&apos;t posted anything yet. Select any passage in the
knowledgebase and use the toolbar to share it to your feed.
</p>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
/**
* /u/me/posts — the owner's Instagram-style scroll feed.
*
* Reached by tapping a thumbnail from the /u/me Posts grid. The clicked
* post's id arrives as a `#post-<id>` hash, and `scroll-mt-*` on each
* card spaces it below the sticky header so the anchor lands cleanly.
*
* Owner controls (Delete + Update background) are mounted per-card.
*/
import { useEffect, useRef, useState } from "react";
import { useAuth } from "@/components/auth/auth-provider";
import { PostScrollCard } from "@/components/social/post-scroll-card";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { posts, SocialError, type Post } from "@/lib/social";
export default function MyPostsFeedPage() {
const { user } = useAuth();
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const scrolledToHashRef = useRef(false);
useEffect(() => {
if (!user) return;
setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
.catch((err) =>
setError(
err instanceof SocialError ? err.message : "Could not load posts.",
),
)
.finally(() => setLoading(false));
}, [user]);
// Hash anchor: when the feed mounts, jump to the post identified in the
// URL hash. Defer one tick so the cards have laid out and `scroll-mt-*`
// can position the anchor below the sticky header.
useEffect(() => {
if (loading || feed.length === 0 || scrolledToHashRef.current) return;
if (typeof window === "undefined") return;
const hash = window.location.hash;
if (!hash) return;
scrolledToHashRef.current = true;
requestAnimationFrame(() => {
const el = document.getElementById(hash.slice(1));
if (el) el.scrollIntoView({ block: "start", behavior: "auto" });
});
}, [loading, feed.length]);
async function performDelete() {
const id = pendingDelete;
if (!id) return;
const prev = feed;
setFeed((rows) => rows.filter((p) => p.id !== id));
try {
await posts.delete(id);
} catch (err) {
setFeed(prev);
const msg =
err instanceof SocialError ? err.message : "Could not delete.";
setError(msg);
// Re-throw so ConfirmModal surfaces the failure and stays open.
throw new Error(msg);
}
}
function onPostUpdated(updated: Post) {
setFeed((rows) => rows.map((p) => (p.id === updated.id ? updated : p)));
}
if (!user) return null;
return (
<main className="flex-1 max-w-xl w-full mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-6">
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : feed.length === 0 ? (
<p className="text-sm text-muted-foreground">No posts yet.</p>
) : (
feed.map((p) => (
<PostScrollCard
key={p.id}
post={p}
onDelete={(id) => setPendingDelete(id)}
onUpdated={onPostUpdated}
/>
))
)}
<ConfirmModal
open={!!pendingDelete}
onClose={() => setPendingDelete(null)}
title="Delete this post?"
message="The post will be removed permanently from your feed and from any collections it's in. This can't be undone."
confirmLabel="Delete post"
onConfirm={performDelete}
/>
</main>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { auth, AuthError } from "@/lib/auth";
import { useAuth } from "@/components/auth/auth-provider";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
function VerifyEmailContent() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const { refresh } = useAuth();
const [status, setStatus] = useState<"pending" | "ok" | "error">("pending");
const [message, setMessage] = useState<string>("");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Verification link is missing a token.");
return;
}
void (async () => {
try {
await auth.verifyEmail(token);
// Pull the freshly-verified user so the banner disappears immediately.
await refresh();
setStatus("ok");
} catch (err) {
setStatus("error");
setMessage(err instanceof AuthError ? err.message : "Verification failed.");
}
})();
}, [token, refresh]);
return (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<Card>
<CardHeader>
<CardTitle className="text-2xl">
{status === "pending"
? "Verifying your email…"
: status === "ok"
? "Email verified ✓"
: "Verification failed"}
</CardTitle>
<CardDescription>
{status === "pending"
? "One moment."
: status === "ok"
? "Thanks — your email is confirmed."
: message}
</CardDescription>
</CardHeader>
<CardContent>
<Link
href="/"
className="text-primary underline-offset-4 hover:underline"
>
Back to home
</Link>
</CardContent>
</Card>
</div>
</main>
);
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={null}>
<VerifyEmailContent />
</Suspense>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
/**
* Animated audio wave bars — used in the minimized player pill
* and the "Now Playing" button.
*
* @param paused – when true the bars flatten to a static state
* @param colorClassName – Tailwind color class for the bars (default: "bg-current")
* @param className – additional classes on the container
* @param barCount – number of bars (default: 4)
* @param size – "sm" (10px tall) | "md" (12px tall, default) | "lg" (16px tall)
*/
export function AudioWaveAnimation({
paused = false,
colorClassName = "bg-current",
className,
barCount = 4,
size = "md",
}: {
paused?: boolean;
colorClassName?: string;
className?: string;
barCount?: number;
size?: "sm" | "md" | "lg";
}) {
const config = {
sm: { height: 10, minH: 2, barW: "w-[2px]", containerH: "h-2.5" },
md: { height: 12, minH: 3, barW: "w-[3px]", containerH: "h-3" },
lg: { height: 16, minH: 3, barW: "w-[3px]", containerH: "h-4" },
}[size];
return (
<div
className={cn("flex items-end gap-[2px]", config.containerH, className)}
>
{Array.from({ length: barCount }, (_, i) => (
<motion.div
key={i}
className={cn(config.barW, "rounded-full", colorClassName)}
animate={{
height: paused
? config.minH
: [config.minH, config.height, config.minH],
}}
transition={{
duration: 0.8,
repeat: paused ? 0 : Infinity,
repeatType: "mirror",
ease: "easeInOut",
delay: i * 0.15,
}}
/>
))}
</div>
);
}
+466 -260
View File
@@ -1,10 +1,12 @@
"use client";
import { useRef, useCallback, useEffect } from "react";
import { useRef, useCallback, useEffect, useState } from "react";
import { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave";
import { cn } from "@/lib/utils";
import Link from "next/link";
import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import {
Play,
Pause,
@@ -18,6 +20,64 @@ import {
Loader2,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Mobile auto-minimize hook
// ---------------------------------------------------------------------------
const MINIMIZE_DELAY = 8000;
function useIsMobile() {
const [mobile, setMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
setMobile(mq.matches);
const handler = (e: MediaQueryListEvent) => setMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return mobile;
}
/**
* Returns [minimized, setMinimized, resetTimer].
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity on mobile.
* Call `resetTimer()` on any user interaction to restart the countdown.
*/
function useAutoMinimize(hasTrack: boolean) {
const [minimized, setMinimized] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const startTimer = useCallback(() => {
clearTimer();
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
}, [clearTimer]);
const resetTimer = useCallback(() => {
setMinimized(false);
startTimer();
}, [startTimer]);
// Start timer when track begins
useEffect(() => {
if (hasTrack) {
startTimer();
} else {
clearTimer();
setMinimized(false);
}
return clearTimer;
}, [hasTrack, startTimer, clearTimer]);
return { minimized, setMinimized, resetTimer } as const;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -230,6 +290,28 @@ export function FloatingPlayer() {
} = useAudioPlayer();
const playerRef = useRef<HTMLDivElement>(null);
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
const isMobile = useIsMobile();
const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack);
// Watch for the selection toolbar appearing/disappearing in the DOM
useEffect(() => {
if (!currentTrack) return;
const check = () => {
const toolbar = document.querySelector("[data-selection-toolbar]");
// The toolbar wrapper is always present; check if it has visible children
setSelectionToolbarVisible(!!toolbar && toolbar.childElementCount > 0);
};
check();
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [currentTrack]);
// Add bottom padding to body when player is visible
useEffect(() => {
@@ -240,47 +322,81 @@ export function FloatingPlayer() {
};
}, [currentTrack]);
// Wrap interactions to reset the minimize timer
const interact = useCallback(
<T extends unknown[]>(fn: (...args: T) => void) =>
(...args: T) => {
resetTimer();
fn(...args);
},
[resetTimer],
);
const iTogglePlayPause = interact(togglePlayPause);
const iSeekTo = interact(seekTo);
const iSetVolume = interact(setVolume);
const iToggleMute = interact(toggleMute);
const iNext = interact(next);
const iPrevious = interact(previous);
const iStop = interact(stop);
// Minimize when clicking/tapping outside the player
useEffect(() => {
if (!currentTrack || minimized) return;
function handleOutsideClick(e: PointerEvent) {
const player = playerRef.current;
if (!player) return;
if (player.contains(e.target as Node)) return;
setMinimized(true);
}
document.addEventListener("pointerdown", handleOutsideClick);
return () =>
document.removeEventListener("pointerdown", handleOutsideClick);
}, [currentTrack, minimized, setMinimized]);
// Keyboard shortcuts when player is focused
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case " ":
e.preventDefault();
togglePlayPause();
iTogglePlayPause();
break;
case "ArrowLeft":
e.preventDefault();
seekTo(Math.max(0, currentTime - 10));
iSeekTo(Math.max(0, currentTime - 10));
break;
case "ArrowRight":
e.preventDefault();
seekTo(Math.min(duration, currentTime + 10));
iSeekTo(Math.min(duration, currentTime + 10));
break;
case "ArrowUp":
e.preventDefault();
setVolume(Math.min(1, volume + 0.05));
iSetVolume(Math.min(1, volume + 0.05));
break;
case "ArrowDown":
e.preventDefault();
setVolume(Math.max(0, volume - 0.05));
iSetVolume(Math.max(0, volume - 0.05));
break;
case "m":
case "M":
e.preventDefault();
toggleMute();
iToggleMute();
break;
case "Escape":
e.preventDefault();
stop();
iStop();
break;
}
},
[
togglePlayPause,
seekTo,
setVolume,
toggleMute,
stop,
iTogglePlayPause,
iSeekTo,
iSetVolume,
iToggleMute,
iStop,
currentTime,
duration,
volume,
@@ -292,269 +408,359 @@ export function FloatingPlayer() {
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
const hasPrev = currentIndex > 0;
const coverSrc = currentTrack.coverImage || FALLBACK_COVER;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
// ── Hide entirely when selection toolbar is active ──
if (selectionToolbarVisible) return null;
// ── Render ──
return (
<div
ref={playerRef}
data-audio-player
role="region"
aria-label="Audio player"
tabIndex={0}
onKeyDown={handleKeyDown}
className={cn(
// Centered floating bar
"fixed z-40 bottom-5 left-1/2 -translate-x-1/2",
// Narrower width
"w-[calc(100%-2rem)] max-w-[680px]",
// Forced dark surface for contrast regardless of theme
"bg-[#1c1c1e] text-white",
"border border-white/10",
"rounded-2xl",
"shadow-2xl shadow-black/25",
// Animation
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300",
// Clip seeker to rounded top corners
"overflow-hidden",
)}
>
{/* Thin progress bar — absolutely positioned at top edge */}
<ProgressBar
currentTime={currentTime}
duration={duration}
onSeek={seekTo}
thin
/>
{/* Time row — desktop (hidden on mobile, mobile has its own) */}
<div className="hidden md:flex items-center justify-between px-5 pt-1.5">
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(currentTime)}
</span>
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(duration)}
</span>
</div>
{/* ── Desktop layout ── */}
<div className="hidden md:flex items-center h-[64px] px-5 gap-5">
{/* Left: Cover + Track info */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<AnimatePresence mode="popLayout">
{minimized ? (
<motion.button
key="mini-pill"
data-audio-player
type="button"
onClick={() => {
setMinimized(false);
resetTimer();
}}
aria-label="Expand audio player"
initial={{ opacity: 0, scale: 0.5, bottom: -60 }}
animate={{
opacity: 1,
scale: 1,
bottom: selectionToolbarVisible ? 90 : 24,
}}
exit={{ opacity: 0, scale: 0.5, bottom: -60 }}
transition={{ type: "spring", damping: 20, stiffness: 400 }}
className={cn(
"fixed z-40 right-4",
"size-14 rounded-full",
"shadow-2xl shadow-black/30",
"overflow-hidden",
"ring-2 ring-white/20",
)}
>
{/* Cover image */}
<div className="size-12 rounded-lg overflow-hidden bg-muted shrink-0 relative">
<Image
src={coverSrc}
alt=""
fill
className="object-cover"
unoptimized
<Image
src={coverSrc}
alt=""
fill
className="object-cover"
unoptimized
/>
{/* Dark overlay */}
<div className="absolute inset-0 bg-black/40" />
{/* Circular progress ring */}
<svg
className="absolute inset-0 size-full -rotate-90"
viewBox="0 0 56 56"
>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="white"
strokeOpacity={0.15}
strokeWidth="2"
/>
<circle
cx="28"
cy="28"
r="26"
fill="none"
stroke="white"
strokeOpacity={0.8}
strokeWidth="2"
strokeDasharray={2 * Math.PI * 26}
strokeDashoffset={2 * Math.PI * 26 * (1 - progress / 100)}
strokeLinecap="round"
className="transition-[stroke-dashoffset] duration-300"
/>
</svg>
{/* Audio wave indicator */}
<div className="absolute inset-0 flex items-center justify-center">
<AudioWaveAnimation
paused={!isPlaying}
colorClassName="bg-white/90"
/>
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate leading-tight">
{currentTrack.title}
</p>
{currentTrack.subtitle && (
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
{currentTrack.subtitle}
</p>
)}
</motion.button>
) : (
<motion.div
key="full-player"
ref={playerRef}
data-audio-player
role="region"
aria-label="Audio player"
tabIndex={0}
onKeyDown={handleKeyDown}
initial={{ opacity: 0, bottom: -80 }}
animate={{
opacity: 1,
bottom: selectionToolbarVisible ? 64 : 20,
}}
exit={{ opacity: 0, bottom: -80 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className={cn(
// Centered floating bar
"fixed z-40 left-1/2 -translate-x-1/2",
// Narrower width
"w-[calc(100%-2rem)] max-w-[680px]",
// Forced dark surface for contrast regardless of theme
"bg-[#1c1c1e] text-white",
"border border-white/10",
"rounded-2xl",
"shadow-2xl shadow-black/25",
// Clip seeker to rounded top corners
"overflow-hidden",
)}
>
{/* Thin progress bar — absolutely positioned at top edge */}
<ProgressBar
currentTime={currentTime}
duration={duration}
onSeek={iSeekTo}
thin
/>
{/* Time row — desktop (hidden on mobile, mobile has its own) */}
<div className="hidden md:flex items-center justify-between px-5 pt-1.5">
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(currentTime)}
</span>
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(duration)}
</span>
</div>
</div>
{/* Center: Transport controls */}
<div className="flex items-center gap-1.5">
<TransportButton
onClick={previous}
disabled={!hasPrev && currentTime <= 3}
label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipBack className="size-4" />
</TransportButton>
{/* ── Desktop layout ── */}
<div className="hidden md:flex items-center h-[64px] px-5 gap-5">
{/* Left: Cover + Track info */}
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Cover image */}
<div className="size-12 rounded-lg overflow-hidden bg-muted shrink-0 relative">
<Image
src={coverSrc}
alt=""
fill
className="object-cover"
unoptimized
/>
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate leading-tight">
{currentTrack.title}
</p>
{currentTrack.subtitle && (
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
{currentTrack.subtitle}
</p>
)}
</div>
</div>
<TransportButton
onClick={togglePlayPause}
label={isPlaying ? "Pause" : "Play"}
size="large"
className="bg-white text-black hover:bg-white/90"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</TransportButton>
{/* Center: Transport controls */}
<div className="flex items-center gap-1.5">
<TransportButton
onClick={iPrevious}
disabled={!hasPrev && currentTime <= 3}
label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipBack className="size-4" />
</TransportButton>
<TransportButton
onClick={next}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipForward className="size-4" />
</TransportButton>
</div>
<TransportButton
onClick={iTogglePlayPause}
label={isPlaying ? "Pause" : "Play"}
size="large"
className="bg-white text-black hover:bg-white/90"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</TransportButton>
{/* Right: Volume + Source link + Close */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Volume control */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={toggleMute}
aria-label={isMuted ? "Unmute" : "Mute"}
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<VolumeIcon
volume={volume}
isMuted={isMuted}
className="size-4"
/>
</button>
<input
type="range"
min={0}
max={100}
step={1}
value={isMuted ? 0 : Math.round(volume * 100)}
onChange={(e) => setVolume(Number(e.target.value) / 100)}
aria-label="Volume"
className={cn(
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:size-3",
"[&::-webkit-slider-thumb]:rounded-full",
"[&::-webkit-slider-thumb]:bg-white",
"[&::-webkit-slider-thumb]:cursor-pointer",
"[&::-moz-range-thumb]:size-3",
"[&::-moz-range-thumb]:rounded-full",
"[&::-moz-range-thumb]:bg-white",
"[&::-moz-range-thumb]:border-0",
"[&::-moz-range-thumb]:cursor-pointer",
<TransportButton
onClick={iNext}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipForward className="size-4" />
</TransportButton>
</div>
{/* Right: Volume + Source link + Close */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Volume control */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={iToggleMute}
aria-label={isMuted ? "Unmute" : "Mute"}
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<VolumeIcon
volume={volume}
isMuted={isMuted}
className="size-4"
/>
</button>
<input
type="range"
min={0}
max={100}
step={1}
value={isMuted ? 0 : Math.round(volume * 100)}
onChange={(e) => iSetVolume(Number(e.target.value) / 100)}
aria-label="Volume"
className={cn(
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:size-3",
"[&::-webkit-slider-thumb]:rounded-full",
"[&::-webkit-slider-thumb]:bg-white",
"[&::-webkit-slider-thumb]:cursor-pointer",
"[&::-moz-range-thumb]:size-3",
"[&::-moz-range-thumb]:rounded-full",
"[&::-moz-range-thumb]:bg-white",
"[&::-moz-range-thumb]:border-0",
"[&::-moz-range-thumb]:cursor-pointer",
)}
/>
</div>
{/* Source link */}
{currentTrack.sourceUrl && (
<Link
href={currentTrack.sourceUrl}
aria-label="Go to source page"
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<ExternalLink className="size-3.5" />
</Link>
)}
/>
{/* Minimize (playing) or Close (paused) */}
<button
type="button"
onClick={isPlaying ? () => setMinimized(true) : iStop}
aria-label={isPlaying ? "Minimize player" : "Close player"}
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Source link */}
{currentTrack.sourceUrl && (
<Link
href={currentTrack.sourceUrl}
aria-label="Go to source page"
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<ExternalLink className="size-3.5" />
</Link>
)}
{/* Close */}
<button
type="button"
onClick={stop}
aria-label="Close player"
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
{/* ── Mobile layout ── */}
<div
className="flex md:hidden flex-col px-4 py-3 gap-2.5"
onPointerDown={resetTimer}
>
<X className="size-4" />
</button>
</div>
</div>
{/* Time row — right below seeker */}
<div className="flex items-center justify-between px-0.5">
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(currentTime)}
</span>
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(duration)}
</span>
</div>
{/* ── Mobile layout ── */}
<div className="flex md:hidden flex-col px-4 py-3 gap-2.5">
{/* Time row — right below seeker */}
<div className="flex items-center justify-between px-0.5">
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(currentTime)}
</span>
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(duration)}
</span>
</div>
{/* Row 1: Cover + Title + Source + Close */}
<div className="flex items-center gap-3">
{/* Cover image */}
<div className="size-10 rounded-lg overflow-hidden bg-muted shrink-0 relative">
<Image
src={coverSrc}
alt=""
fill
className="object-cover"
unoptimized
/>
</div>
{/* Row 1: Cover + Title + Source + Close */}
<div className="flex items-center gap-3">
{/* Cover image */}
<div className="size-10 rounded-lg overflow-hidden bg-muted shrink-0 relative">
<Image
src={coverSrc}
alt=""
fill
className="object-cover"
unoptimized
/>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate leading-tight">
{currentTrack.title}
</p>
{currentTrack.subtitle && (
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
{currentTrack.subtitle}
</p>
)}
</div>
{/* Source link */}
{currentTrack.sourceUrl && (
<Link
href={currentTrack.sourceUrl}
aria-label="Go to source page"
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0"
>
<ExternalLink className="size-4" />
</Link>
)}
{/* Minimize (playing) or Close (paused) */}
<button
type="button"
onClick={isPlaying ? () => setMinimized(true) : iStop}
aria-label={isPlaying ? "Minimize player" : "Close player"}
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
>
<X className="size-4" />
</button>
</div>
{/* Row 2: Prev + Play/Pause + Next — centered */}
<div className="flex items-center justify-center gap-4 py-1">
<TransportButton
onClick={iPrevious}
disabled={!hasPrev && currentTime <= 3}
label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipBack className="size-5" />
</TransportButton>
<TransportButton
onClick={iTogglePlayPause}
label={isPlaying ? "Pause" : "Play"}
size="large"
className="bg-white text-black hover:bg-white/90 !size-12"
>
{isLoading ? (
<Loader2 className="size-5 animate-spin" />
) : isPlaying ? (
<Pause className="size-5" />
) : (
<Play className="size-5 ml-0.5" />
)}
</TransportButton>
<TransportButton
onClick={iNext}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipForward className="size-5" />
</TransportButton>
</div>
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate leading-tight">
{currentTrack.title}
</p>
{currentTrack.subtitle && (
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
{currentTrack.subtitle}
</p>
)}
</div>
{/* Source link */}
{currentTrack.sourceUrl && (
<Link
href={currentTrack.sourceUrl}
aria-label="Go to source page"
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0"
>
<ExternalLink className="size-4" />
</Link>
)}
{/* Close */}
<button
type="button"
onClick={stop}
aria-label="Close player"
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
>
<X className="size-4" />
</button>
</div>
{/* Row 2: Prev + Play/Pause + Next — centered */}
<div className="flex items-center justify-center gap-4 py-1">
<TransportButton
onClick={previous}
disabled={!hasPrev && currentTime <= 3}
label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipBack className="size-5" />
</TransportButton>
<TransportButton
onClick={togglePlayPause}
label={isPlaying ? "Pause" : "Play"}
size="large"
className="bg-white text-black hover:bg-white/90 !size-12"
>
{isLoading ? (
<Loader2 className="size-5 animate-spin" />
) : isPlaying ? (
<Pause className="size-5" />
) : (
<Play className="size-5 ml-0.5" />
)}
</TransportButton>
<TransportButton
onClick={next}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipForward className="size-5" />
</TransportButton>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+1
View File
@@ -1,3 +1,4 @@
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
export type { AudioTrack } from "./audio-context";
export { FloatingPlayer } from "./floating-player";
export { AudioWaveAnimation } from "./audio-wave";
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
/**
* Visual wrapper used by every /(auth) page. Centralises the card chrome
* so the per-page files only deal with form state.
*/
export function AuthCard({
title,
description,
footer,
children,
}: {
title: string;
description?: string;
footer?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="pb-8">
<CardTitle className="text-2xl">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
{footer ? (
<div className="px-6 pb-6 text-sm text-muted-foreground">{footer}</div>
) : null}
</Card>
);
}
export function AuthFooterLink({
prefix,
href,
label,
}: {
prefix: string;
href: string;
label: string;
}) {
return (
<p>
{prefix}{" "}
<Link href={href} className="text-primary underline-offset-4 hover:underline">
{label}
</Link>
</p>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
/**
* AuthGate — a global "require login before continuing" prompt.
*
* Why a gate at all? When an unauthenticated user clicks something that
* needs them to be logged in (Bookmark, Save to collection, etc.) we want to
*
* 1. Show a login/signup modal *on the same page* (no redirect).
* 2. Let them finish the original action right after they log in.
* 3. Keep their URL — bookmarks, scroll position, selection — untouched.
*
* The trick is the imperative `requireAuth()` call: it returns a Promise that
* resolves `true` once the user is authenticated, or `false` if they close
* the modal without doing so. Calling code reads like a sync check:
*
* if (!(await requireAuth())) return;
* // ... proceed with the protected API call
*
* Implementation sketch:
* - We hold a single "pending" promise resolver in a ref so a follow-on
* call to requireAuth (e.g. from another button) hooks onto the same
* modal session.
* - When AuthProvider reports a user (via useAuth) and the modal is open,
* we resolve the promise(s) with `true` and close.
* - When the modal closes without a user, we resolve with `false`.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useAuth } from "./auth-provider";
import { AuthModal } from "./auth-modal";
type AuthGateValue = {
/** Returns true if the user is (or becomes) authenticated; false if they dismiss. */
requireAuth: () => Promise<boolean>;
/** Force-open the modal regardless of auth state — for explicit "Log in" buttons. */
openModal: () => void;
};
const AuthGateContext = createContext<AuthGateValue | null>(null);
export function AuthGateProvider({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const [open, setOpen] = useState(false);
const resolversRef = useRef<((v: boolean) => void)[]>([]);
const requireAuth = useCallback(async () => {
// Fast path: already logged in.
if (user) return true;
setOpen(true);
return new Promise<boolean>((resolve) => {
resolversRef.current.push(resolve);
});
}, [user]);
const openModal = useCallback(() => {
if (!user) setOpen(true);
}, [user]);
// Auto-resolve pending promises the moment the user becomes authenticated.
// This is what flips us from "modal open, waiting" to "modal closed, action
// continues" without any extra wiring at the call site.
useEffect(() => {
if (user && (open || resolversRef.current.length > 0)) {
const pending = resolversRef.current;
resolversRef.current = [];
setOpen(false);
pending.forEach((r) => r(true));
}
}, [user, open]);
const onDismiss = useCallback(() => {
const pending = resolversRef.current;
resolversRef.current = [];
setOpen(false);
pending.forEach((r) => r(false));
}, []);
const value = useMemo<AuthGateValue>(
() => ({ requireAuth, openModal }),
[requireAuth, openModal],
);
return (
<AuthGateContext.Provider value={value}>
{children}
{/* Render the modal only when we genuinely need it. `!user` guards
against the brief window during which the AuthProvider hasn't
finished hydrating; we'd rather wait than flash the modal. */}
{open && !user && !loading ? <AuthModal onDismiss={onDismiss} /> : null}
</AuthGateContext.Provider>
);
}
export function useAuthGate(): AuthGateValue {
const ctx = useContext(AuthGateContext);
if (!ctx) {
throw new Error("useAuthGate must be used inside <AuthGateProvider>");
}
return ctx;
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
/**
* AuthModal — login + signup tabs in a portal-rendered dialog.
*
* The two forms share the existing LoginForm / SignUpForm widgets, so the
* behaviour is identical to the standalone /login and /signup pages.
*
* The forms call `onSuccess` after they finish, but the modal does NOT close
* from that callback — AuthGate's effect on `user` change handles the close
* + promise resolution. This lets a single source of truth (AuthProvider's
* user state) drive the whole flow.
*
* No router pushes here — the user's URL stays exactly where it was.
*/
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { LoginForm } from "./login-form";
import { SignUpForm } from "./signup-form";
type Mode = "login" | "signup";
export function AuthModal({ onDismiss }: { onDismiss: () => void }) {
const [mode, setMode] = useState<Mode>("login");
// Esc-to-close — matches the pattern used elsewhere (reading settings,
// search overlay) so the keyboard behavior feels consistent.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onDismiss();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onDismiss]);
// Lock scroll on the body while the modal is open.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
if (typeof document === "undefined") return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={onDismiss}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-modal-title"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<header className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2
id="auth-modal-title"
className="font-[family-name:var(--font-title)] font-semibold text-xl text-foreground"
>
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>
<button
type="button"
onClick={onDismiss}
className="size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
aria-label="Close"
>
<X className="size-4" />
</button>
</header>
{/* Tab toggle */}
<div className="px-5 pt-4 grid grid-cols-2 gap-1 rounded-lg">
<ModeTab active={mode === "login"} onClick={() => setMode("login")}>
Log in
</ModeTab>
<ModeTab
active={mode === "signup"}
onClick={() => setMode("signup")}
>
Sign up
</ModeTab>
</div>
<div className="px-5 py-5">
{mode === "login" ? (
<LoginForm
/* onSuccess is a no-op here — AuthGate watches `user` directly
and closes the modal + resolves the pending promise itself. */
onSuccess={() => {}}
forgotPasswordHref="/forgot-password"
/>
) : (
<SignUpForm onSuccess={() => {}} />
)}
</div>
<footer className="px-5 pb-5 -mt-1 text-xs text-muted-foreground">
{mode === "login" ? (
<>
Need to reset your password?{" "}
<Link
href="/forgot-password"
className="text-primary hover:underline"
onClick={onDismiss}
>
Click here
</Link>
.
</>
) : (
<>
By signing up you agree to our acceptable-use policy. Posts on
your feed are public.
</>
)}
</footer>
</div>
</div>
</>,
document.body,
);
}
function ModeTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"h-10 rounded-md text-sm font-medium transition-colors",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{children}
</button>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { auth, type User } from "@/lib/auth";
/**
* AuthProvider holds the current-user state and exposes the auth actions
* to the rest of the app.
*
* On mount it calls /auth/me to hydrate. Until that completes, `loading`
* is true and consumers should render a placeholder rather than assuming
* the user is logged out.
*
* After a successful login/signup/logout the provider re-fetches /auth/me
* to keep state in sync with the cookie.
*/
type AuthContextValue = {
user: User | null;
loading: boolean;
signUp: (email: string, password: string, name: string) => Promise<User>;
logIn: (email: string, password: string) => Promise<User>;
logOut: () => Promise<void>;
refresh: () => Promise<void>;
/** Replace the user state without a network round-trip (after profile edit). */
setUser: (u: User | null) => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const me = await auth.me();
setUser(me);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const signUp = useCallback(async (email: string, password: string, name: string) => {
const u = await auth.signUp(email, password, name);
setUser(u);
return u;
}, []);
const logIn = useCallback(async (email: string, password: string) => {
const u = await auth.logIn(email, password);
setUser(u);
return u;
}, []);
const logOut = useCallback(async () => {
await auth.logOut();
setUser(null);
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, loading, signUp, logIn, logOut, refresh, setUser }),
[user, loading, signUp, logIn, logOut, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used inside <AuthProvider>");
}
return ctx;
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
/**
* LoginForm — pure form widget reused by both the dedicated /login page and
* the AuthGate modal that pops up when an unauthenticated user tries to use
* a protected feature.
*
* `onSuccess` is invoked once login completes. Callers decide what happens
* next (a page redirect, a modal close, etc.). The form itself doesn't
* navigate — that keeps it environment-agnostic.
*
* `forgotPasswordHref` is configurable so the modal can override (it may
* want to dismiss itself or open a different mode).
*/
import { useState } from "react";
import Link from "next/link";
import { useAuth } from "@/components/auth/auth-provider";
import { AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function LoginForm({
onSuccess,
forgotPasswordHref = "/forgot-password",
showForgotLink = true,
}: {
onSuccess?: () => void;
forgotPasswordHref?: string;
showForgotLink?: boolean;
}) {
const { logIn } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
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 logIn(email, password);
onSuccess?.();
} catch (err) {
setError(err instanceof AuthError ? err.message : "Login failed.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="login-email">Email</Label>
<Input
id="login-email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="login-password">Password</Label>
{showForgotLink ? (
<Link
href={forgotPasswordHref}
className="text-xs text-muted-foreground hover:text-foreground"
>
Forgot?
</Link>
) : null}
</div>
<Input
id="login-password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Logging in…" : "Log in"}
</Button>
</form>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
/**
* SignUpForm — paired with LoginForm. Same callback contract:
* `onSuccess` fires once the new account is logged in; callers decide
* what to do next.
*/
import { useState } from "react";
import { useAuth } from "@/components/auth/auth-provider";
import { AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function SignUpForm({ onSuccess }: { onSuccess?: () => void }) {
const { signUp } = useAuth();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
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 signUp(email, password, name);
onSuccess?.();
} catch (err) {
setError(err instanceof AuthError ? err.message : "Sign-up failed.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="signup-name">Name</Label>
<Input
id="signup-name"
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="signup-email">Email</Label>
<Input
id="signup-email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="signup-password">Password</Label>
<Input
id="signup-password"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">At least 8 characters.</p>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Creating account…" : "Sign up"}
</Button>
</form>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
/**
* UserMenu — the right-most item in SiteHeader. Three states:
*
* 1. Loading (auth context hasn't hydrated) — render a quiet placeholder
* so the row width doesn't jump when the user data arrives.
* 2. Logged out — single "Log in" icon/button.
* 3. Logged in — avatar / initials chip that opens a popover with
* "Profile" and "Log out".
*
* Why hand-rolled instead of `@base-ui/react/popover`: outside-click
* dismissal was unreliable for the avatar trigger here. The pattern below
* mirrors `ReadingSettings`: mousedown listener for outside clicks, keyup
* for Escape. That's already proven across the site and stays simple.
*/
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { UserRound } from "lucide-react";
import { useAuth } from "./auth-provider";
import { cn } from "@/lib/utils";
function initials(name: string): string {
return (
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?"
);
}
export function UserMenu() {
const { user, loading, logOut } = useAuth();
const router = useRouter();
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
// Close on outside click + Escape, mirroring ReadingSettings.
useEffect(() => {
if (!open) return;
function onMouseDown(e: MouseEvent) {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (loading) {
// Placeholder keeps the action row width stable until auth hydrates.
return <span aria-hidden className="size-9 inline-block" />;
}
if (!user) {
// Mobile: compact icon button so the header row fits on 375px viewports.
// Desktop: full "Log in" text — there's plenty of space.
return (
<Link
href="/login"
aria-label="Log in"
className="inline-flex items-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors size-9 justify-center md:size-auto md:h-9 md:px-3 md:gap-1.5"
>
<UserRound className="size-5 md:size-4" />
<span className="hidden md:inline text-sm font-medium">Log in</span>
</Link>
);
}
async function onLogOut() {
setOpen(false);
await logOut();
router.push("/");
router.refresh();
}
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Account menu"
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
>
{user.avatar_url ? (
<Image
src={user.avatar_url}
alt={user.name}
width={36}
height={36}
className="size-full object-cover"
unoptimized
/>
) : (
<span
className={cn(
"flex items-center justify-center size-full text-xs font-semibold",
"bg-primary/15 text-primary",
)}
>
{initials(user.name)}
</span>
)}
</button>
{open ? (
<div
ref={panelRef}
role="menu"
className={cn(
"absolute right-0 top-full mt-2 min-w-48 rounded-xl bg-card",
"border border-border shadow-lg p-1 z-50",
)}
>
<div className="px-3 py-2">
<p className="text-sm font-medium text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{user.email}
</p>
</div>
<div className="h-px bg-border my-1" />
<Link
href="/u/me"
onClick={() => setOpen(false)}
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Profile
</Link>
<button
type="button"
onClick={onLogOut}
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Log out
</button>
</div>
) : null}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { auth, AuthError } from "@/lib/auth";
import { useAuth } from "./auth-provider";
/**
* Persistent top ribbon shown when a logged-in user has not yet verified
* their email. Lets them re-trigger the verification mail without leaving
* the page.
*
* Renders nothing when:
* - auth state is still loading
* - user is logged out
* - user.email_verified_at is set
* - user dismissed it for this session (banner is meant to be persistent
* across navigations but skippable per-tab; we keep the dismissed flag
* in component state so a refresh brings it back)
*/
export function VerifyEmailBanner() {
const { user, loading } = useAuth();
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [dismissed, setDismissed] = useState(false);
if (loading || !user || user.email_verified_at || dismissed) return null;
async function onResend() {
setError(null);
setBusy(true);
try {
await auth.resendVerification();
setSent(true);
} catch (err) {
setError(
err instanceof AuthError ? err.message : "Could not send verification email.",
);
} finally {
setBusy(false);
}
}
return (
<div className="w-full bg-amber-50 dark:bg-amber-950/40 text-amber-900 dark:text-amber-100 border-b border-amber-200/60 dark:border-amber-800/60">
<div className="max-w-5xl mx-auto px-4 py-2 flex flex-wrap items-center justify-between gap-3 text-sm">
<p>
{sent
? "✓ Verification email sent — check your inbox."
: error
? error
: `Verify your email (${user.email}) to secure your account.`}
</p>
<div className="flex items-center gap-2">
{!sent ? (
<button
type="button"
onClick={onResend}
disabled={busy}
className="font-medium underline-offset-2 hover:underline disabled:opacity-60"
>
{busy ? "Sending…" : "Resend email"}
</button>
) : null}
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
className="opacity-70 hover:opacity-100"
>
✕
</button>
</div>
</div>
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { useRef, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { motion } from "framer-motion";
import { Search, CornerDownLeft } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* HeaderSearchButton — a search icon in the page header that expands
* into a full search input on click. Typing a query and pressing Enter
* (or clicking the search icon again) navigates to /search?q=...
*
* State is controlled by the parent (PageHeader) so the header can
* hide/show adjacent elements (logo, reading settings) on mobile.
*
* Pressing Escape or clicking outside collapses the input.
* Keyboard shortcut: "/" focuses the search from anywhere on the page.
*/
/** Map the current pathname to the search `type` filter value. */
function searchTypeFromPath(pathname: string): string | null {
if (pathname.startsWith("/books")) return "books";
if (pathname.startsWith("/letters")) return "letters";
if (pathname.startsWith("/essays")) return "essays";
if (pathname.startsWith("/transcriptions")) return "transcriptions";
if (pathname.startsWith("/glossary")) return "glossary";
if (pathname.startsWith("/journals")) return "journals";
if (pathname.startsWith("/magazines")) return "magazines";
if (pathname.startsWith("/other-documents")) return "other_documents";
return null; // homepage, search page, about, etc. → "all"
}
export function HeaderSearchButton({
expanded,
onExpandedChange,
}: {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
}) {
const queryRef = useRef("");
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const pathname = usePathname();
// Focus input when expanded
useEffect(() => {
if (expanded && inputRef.current) {
const timer = setTimeout(() => inputRef.current?.focus(), 150);
return () => clearTimeout(timer);
}
}, [expanded]);
// Close on outside click
useEffect(() => {
if (!expanded) return;
function handleClick(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onExpandedChange(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [expanded, onExpandedChange]);
// Global "/" shortcut to open search
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
) {
return;
}
if (e.key === "/") {
e.preventDefault();
onExpandedChange(true);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onExpandedChange]);
const handleSubmit = useCallback(() => {
const trimmed = queryRef.current.trim();
const params = new URLSearchParams();
if (trimmed) params.set("q", trimmed);
// Pre-select the search filter matching the current section
const type = searchTypeFromPath(pathname);
if (type) params.set("type", type);
const qs = params.toString();
router.push(`/search${qs ? `?${qs}` : ""}`);
onExpandedChange(false);
queryRef.current = "";
if (inputRef.current) inputRef.current.value = "";
}, [router, onExpandedChange, pathname]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
onExpandedChange(false);
queryRef.current = "";
if (inputRef.current) inputRef.current.value = "";
}
},
[handleSubmit, onExpandedChange],
);
return (
<div
ref={containerRef}
className={cn(
"relative flex items-center",
expanded && "max-md:flex-1 max-md:min-w-0",
)}
>
{expanded ? (
<motion.div
key="search-input"
initial={{ width: 36, opacity: 0 }}
animate={{ width: "100%", opacity: 1 }}
transition={{
width: { type: "spring", stiffness: 400, damping: 30 },
opacity: { duration: 0.15 },
}}
className={cn(
"flex items-center gap-2 h-12 md:h-10 rounded-lg border border-border bg-background px-3 overflow-hidden",
"max-md:w-full",
"md:max-w-100",
)}
>
<input
ref={inputRef}
type="text"
defaultValue=""
autoFocus
onChange={(e) => {
queryRef.current = e.target.value;
}}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="flex-1 bg-transparent text-md md:text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
/>
<button
type="button"
onClick={handleSubmit}
className="shrink-0 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Search
<CornerDownLeft className="size-3 opacity-70" />
</button>
</motion.div>
) : (
<motion.button
key="search-icon"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.12 }}
type="button"
onClick={() => onExpandedChange(true)}
aria-label="Search (press /)"
title="Search (press /)"
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Search className="size-4.5" />
</motion.button>
)}
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
/**
* KnowledgebaseIndex — the books / letters / lectures / essays / etc.
* directory layout. Rendered as the search page's empty state so an
* untyped search bar still gives users an editorial entry point into the
* library.
*
* Two sections, top to bottom:
* 1. Start here — three primary entry points (Books, Letters, Lectures)
* rendered as full-width feature tiles with longer copy.
* 2. Explore — a six-card asymmetric grid for the remaining sections.
*
* No hero block; the parent page (Search) carries its own hero so two
* heroes don't stack on top of each other.
*/
import Link from "next/link";
import {
ArrowUpRight,
BookA,
BookOpen,
FileStack,
Mail,
Mic,
Newspaper,
NotebookPen,
PenLine,
Users,
} from "lucide-react";
type Feature = {
href: string;
label: string;
blurb: string;
count?: string;
icon: React.ComponentType<{ className?: string }>;
};
const FEATURED: Feature[] = [
{
href: "/books",
label: "Books",
blurb:
"Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and 34 more — each verse with Devanāgarī, transliteration, synonyms, translation and purport.",
count: "37 books",
icon: BookOpen,
},
{
href: "/letters",
label: "Letters",
blurb:
"Three decades of personal correspondence to disciples, family, and officials. Browse by year, recipient, or location.",
count: "6,866 letters",
icon: Mail,
},
{
href: "/transcriptions",
label: "Lectures & Conversations",
blurb:
"Lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text and audio.",
count: "3,787 transcripts",
icon: Mic,
},
];
const EXPLORE: Feature[] = [
{
href: "/essays",
label: "Essays",
blurb: "Foundational standalone essays.",
count: "23 essays",
icon: PenLine,
},
{
href: "/magazines",
label: "Back to Godhead",
blurb: "The original 1944 – 1973 magazine archive.",
count: "1,124 articles",
icon: Newspaper,
},
{
href: "/journals",
label: "Journals",
blurb: "The Jaladuta Diary and The Beginning.",
count: "300 entries",
icon: NotebookPen,
},
{
href: "/glossary",
label: "Glossary",
blurb: "Sanskrit and Vedic terms with definitions.",
count: "2,749 terms",
icon: BookA,
},
{
href: "/disciples",
label: "Disciples",
blurb: "Initiated disciples 1966 – 1977.",
count: "4,747 names",
icon: Users,
},
{
href: "/other-documents",
label: "Other Documents",
blurb: "Miscellaneous analyses and writings.",
count: "12 documents",
icon: FileStack,
},
];
export function KnowledgebaseIndex() {
return (
<div>
<section className="py-4">
<SectionHeading eyebrow="Start here" title="Three doors in" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
{FEATURED.map((f) => (
<FeaturedTile key={f.href} feature={f} />
))}
</div>
</section>
<section className="py-6 sm:py-8">
<SectionHeading eyebrow="Explore" title="The wider library" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{EXPLORE.map((f) => (
<ExploreTile key={f.href} feature={f} />
))}
</div>
</section>
</div>
);
}
function FeaturedTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group relative flex flex-col h-full rounded-2xl bg-card ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all p-6"
>
<div className="flex items-start justify-between">
<div className="flex items-center justify-center size-11 rounded-xl bg-primary/12 text-primary">
<Icon className="size-5" />
</div>
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] leading-tight text-foreground">
{feature.label}
</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed flex-1">
{feature.blurb}
</p>
{feature.count ? (
<p className="mt-5 text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground">
{feature.count}
</p>
) : null}
</Link>
);
}
function ExploreTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group flex items-start gap-4 rounded-xl bg-card/70 ring-1 ring-foreground/8 hover:ring-foreground/20 hover:bg-card transition-all p-4 sm:p-5"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-secondary text-secondary-foreground/80 shrink-0">
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground leading-tight">
{feature.label}
</h4>
<ArrowUpRight className="size-3.5 text-muted-foreground/50 group-hover:text-foreground transition-colors shrink-0" />
</div>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{feature.blurb}
</p>
{feature.count ? (
<p className="text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground/80 mt-2">
{feature.count}
</p>
) : null}
</div>
</Link>
);
}
function SectionHeading({
eyebrow,
title,
}: {
eyebrow: string;
title: string;
}) {
return (
<div className="mb-6 flex items-baseline gap-4">
<span className="text-xs uppercase tracking-[0.2em] font-semibold text-muted-foreground">
{eyebrow}
</span>
<span className="flex-1 h-px bg-border" />
<h2 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground">
{title}
</h2>
</div>
);
}
+77 -9
View File
@@ -1,28 +1,96 @@
import { ReadingSettings } from "@/components/reading-settings";
"use client";
/**
* PageHeader — per-page sub-header carrying the page title / breadcrumb.
*
* The global SiteHeader (mounted in app/layout.tsx) owns the wordmark,
* primary nav, search, reading settings, and user menu. PageHeader is now
* purely typographic — it renders whatever `children` the page passes and
* applies a subtle bordered band so the page's title area has visual weight
* separate from the body content.
*
* Behaviour preserved from the previous (busier) implementation:
* • If sessionStorage has `spsays_from_search`, the first <Link> child is
* hidden and replaced with a "← Back to search results" button. This
* keeps the cross-page-navigation polish that the search page relies on.
*
* Pages that don't want a sub-header (e.g. the redesigned homepage which
* paints its own editorial hero) can simply not render PageHeader.
*/
import { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const SEARCH_RETURN_KEY = "spsays_from_search";
export function PageHeader({
children,
sticky = false,
className,
bare = false,
sticky = false,
}: {
children: React.ReactNode;
/** Drop the bordered chrome — useful for pages that want flush title text. */
bare?: boolean;
/**
* Stick to the top below the global SiteHeader. Used by reading pages
* so the chapter title stays visible as the user scrolls long content.
* The offset is `top-12 sm:top-14` — SiteHeader is `h-12 sm:h-14`, so
* these values land the PageHeader flush with its bottom edge with no
* visible gap.
*/
sticky?: boolean;
className?: string;
}) {
const router = useRouter();
const pathname = usePathname();
// Read (and consume) the search-return flag once on first render.
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(
"border-b border-border bg-card px-6",
sticky ? "py-4 sticky top-0 z-20" : "py-4",
"px-4 sm:px-6",
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
sticky && "sticky top-12 sm:top-14 z-20",
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 />
</div>
<div className="max-w-5xl mx-auto">
{fromSearch ? (
<>
<button
type="button"
onClick={() => {
setFromSearch(false);
router.back();
}}
className="block text-xs text-muted-foreground hover:text-foreground transition-colors mb-2"
>
&larr; Back to search results
</button>
{/* Hide the page's own breadcrumb link (always the first <a>) */}
<div className="[&>a:first-child]:hidden">{children}</div>
</>
) : (
children
)}
</div>
</header>
);
+225
View File
@@ -0,0 +1,225 @@
"use client";
/**
* ProfileEditForm — the editor block used in /u/me's edit mode.
*
* Three editable fields:
* • Display name (PATCH /users/me { name })
* • Handle (PATCH /users/me { handle }) — 409 if taken
* • Avatar (R2 presign + PATCH /users/me { avatar_url })
*
* `onDone` is invoked after a successful save OR when the user clicks
* "Cancel" — the parent flips back to view mode.
*
* No log-out button here — that's handled by the sidebar footer so it lives
* outside the edit context.
*/
import { useRef, useState } from "react";
import Image from "next/image";
import { ImagePlus, Save, X } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { auth, AuthError, type User } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function ProfileEditForm({
user,
onDone,
}: {
user: User;
onDone: () => void;
}) {
const { setUser } = useAuth();
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(user.name);
const [handle, setHandle] = useState(user.handle);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty =
name.trim() !== user.name ||
handle.trim() !== user.handle ||
avatarUrl !== user.avatar_url;
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSaving(true);
try {
// Patch only what's changed — keeps the audit log tidy and avoids
// bumping updated_at when the user just clicked "Save" without edits.
const updated = await auth.updateProfile(
name.trim() !== user.name ? name.trim() : undefined,
avatarUrl === user.avatar_url ? undefined : (avatarUrl ?? ""),
handle.trim() !== user.handle ? handle.trim() : undefined,
);
setUser(updated);
onDone();
} catch (err) {
setError(err instanceof AuthError ? err.message : "Could not save.");
} finally {
setSaving(false);
}
}
async function onAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (fileInput.current) fileInput.current.value = "";
if (!file) return;
setError(null);
setUploading(true);
try {
const publicURL = await auth.uploadAvatar(file);
setAvatarUrl(publicURL);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Upload failed.");
} finally {
setUploading(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6 rounded-2xl bg-card ring-1 ring-foreground/10 p-5 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="font-[family-name:var(--font-title)] text-xl sm:text-2xl font-semibold text-foreground">
Edit profile
</h2>
<button
type="button"
onClick={onDone}
aria-label="Cancel"
className="size-9 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
>
<X className="size-4" />
</button>
</div>
{/* Avatar — same widget pattern as the old /profile form */}
<section className="flex items-center gap-4">
<AvatarPreview src={avatarUrl} name={user.name} />
<div className="flex flex-col gap-2">
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={onAvatarChange}
/>
<Button
variant="outline"
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploading || saving}
className="h-10 px-4 text-sm gap-1.5"
>
<ImagePlus className="size-4" />
{uploading ? "Uploading…" : avatarUrl ? "Change" : "Upload"}
</Button>
{avatarUrl ? (
<Button
variant="ghost"
type="button"
onClick={() => setAvatarUrl(null)}
disabled={uploading || saving}
className="h-10 px-4 text-sm"
>
Remove
</Button>
) : null}
</div>
</section>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="profile-name">Display name</Label>
<Input
id="profile-name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={120}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-handle">Handle</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">@</span>
<Input
id="profile-handle"
value={handle}
onChange={(e) => setHandle(e.target.value)}
maxLength={32}
pattern="[a-zA-Z0-9._-]+"
required
/>
</div>
<p className="text-xs text-muted-foreground">
Letters, digits, dashes. Your public profile lives at /u/{handle || user.handle}.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-email">Email</Label>
<Input id="profile-email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email changes aren&apos;t supported yet.
</p>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button
type="button"
variant="ghost"
onClick={onDone}
className="h-10 px-4 text-sm"
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || uploading || !dirty}
className="h-10 px-4 text-sm gap-1.5"
>
<Save className="size-4" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
);
}
function AvatarPreview({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={80}
height={80}
className="size-20 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-20 rounded-full bg-muted flex items-center justify-center text-2xl font-medium text-muted-foreground ring-1 ring-foreground/10">
{initials}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/**
* ProfileHeader — Instagram-style centered header used at the top of
* /u/me and /u/[handle]. Avatar, display name, @handle, a single stat
* line, and an optional action slot (Edit profile button on owner view).
*
* Replaces the older ProfileViewHeader (left-aligned avatar + sidebar).
*/
import Image from "next/image";
import Link from "next/link";
export function ProfileHeader({
avatarURL,
name,
handle,
postCount,
joinedAt,
editHref,
}: {
avatarURL: string | null;
name: string;
handle: string;
postCount?: number;
joinedAt?: string;
/** When provided, shows an "Edit profile" CTA below the handle. */
editHref?: string;
}) {
return (
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
<Avatar src={avatarURL} name={name} />
<div className="space-y-1">
<h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight">
{name}
</h1>
<p className="text-sm text-muted-foreground">@{handle}</p>
{typeof postCount === "number" || joinedAt ? (
<p className="text-xs text-muted-foreground">
{typeof postCount === "number" ? (
<>
<span className="tabular-nums">{postCount}</span> post
{postCount === 1 ? "" : "s"}
</>
) : null}
{typeof postCount === "number" && joinedAt ? " · " : null}
{joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()}</> : null}
</p>
) : null}
</div>
{editHref ? (
<Link
href={editHref}
className="inline-flex items-center justify-center h-9 px-5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Edit profile
</Link>
) : null}
</header>
);
}
function Avatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={112}
height={112}
className="size-24 sm:size-28 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-24 sm:size-28 rounded-full bg-primary/15 text-primary flex items-center justify-center text-3xl font-semibold ring-1 ring-foreground/10">
{initials}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
/**
* ProfileSidebar — the navigation rail for /u/me.
*
* • Desktop (md+): permanent left column, ~240px wide, sticky below the
* SiteHeader so it stays in view while content scrolls.
* • Mobile: a hamburger button reveals the same nav as a left drawer.
*
* Items are intentionally just three (Profile / Collections / Bookmarks) to
* keep the surface focused; "Log out" sits as a footer action separated by
* a divider.
*/
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
import { User as UserIcon, FolderOpen, Bookmark, LogOut, Menu } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { useAuth } from "@/components/auth/auth-provider";
import type { User } from "@/lib/auth";
import { cn } from "@/lib/utils";
type NavItem = {
href: string;
label: string;
icon: typeof UserIcon;
/** Match only when pathname === href; otherwise startsWith is used. */
exact?: boolean;
};
const ITEMS: NavItem[] = [
{ href: "/u/me", label: "Profile", icon: UserIcon, exact: true },
{ href: "/u/me/collections", label: "Collections", icon: FolderOpen },
{ href: "/u/me/bookmarks", label: "Bookmarks", icon: Bookmark },
];
export function ProfileSidebar({ user }: { user: User }) {
return (
<>
{/* Desktop rail — always visible on md+ */}
<aside className="hidden md:block w-60 shrink-0">
<div className="sticky top-20 space-y-4">
<SidebarHeader user={user} />
<SidebarNav onItemClick={() => {}} />
<SidebarFooter />
</div>
</aside>
{/* Mobile trigger + drawer */}
<MobileSidebar user={user} />
</>
);
}
function MobileSidebar({ user }: { user: User }) {
const [open, setOpen] = useState(false);
return (
<div className="md:hidden mb-4">
<Drawer open={open} onOpenChange={setOpen} direction="left">
<DrawerTrigger
className={cn(
"inline-flex items-center gap-2 rounded-lg border border-border bg-card",
"px-3 h-10 text-sm font-medium text-foreground hover:bg-muted transition-colors",
)}
>
<Menu className="size-4" />
Library menu
</DrawerTrigger>
<DrawerContent direction="left" className="h-full w-72 max-w-[80%]">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.14em] text-sm">
Your library
</DrawerTitle>
<DrawerDescription className="sr-only">
Switch between profile, collections, and bookmarks.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto flex-1 p-4 space-y-4">
<SidebarHeader user={user} />
<SidebarNav onItemClick={() => setOpen(false)} />
<SidebarFooter />
</div>
</DrawerContent>
</Drawer>
</div>
);
}
function SidebarHeader({ user }: { user: User }) {
return (
<div className="flex items-center gap-3 px-3 py-3 rounded-lg bg-card ring-1 ring-foreground/10">
<Avatar src={user.avatar_url} name={user.name} />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">@{user.handle}</p>
</div>
</div>
);
}
function SidebarNav({ onItemClick }: { onItemClick: () => void }) {
const pathname = usePathname();
return (
<nav className="space-y-0.5" aria-label="Library sections">
{ITEMS.map((item) => {
const active = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onItemClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{item.label}
</Link>
);
})}
</nav>
);
}
function SidebarFooter() {
const { logOut } = useAuth();
const router = useRouter();
async function onLogOut() {
await logOut();
router.push("/");
router.refresh();
}
return (
<div className="pt-3 border-t border-border">
<button
type="button"
onClick={onLogOut}
className="flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<LogOut className="size-4" />
Log out
</button>
</div>
);
}
function Avatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={40}
height={40}
className="size-10 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-10 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10 shrink-0">
{initials}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
/**
* ProfileTabs — Instagram-style icon tab strip used under ProfileHeader.
*
* Renders a horizontal row of equal-width icon tabs. The active tab gets
* a top-border indicator and stronger color. Labels are screen-reader
* only by default (Instagram pattern), but a `showLabels` prop opts them
* back in for surfaces with more real estate.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export type ProfileTab = {
href: string;
label: string;
icon: React.ReactNode;
/** Additional pathnames that should mark this tab active. Used to keep
* the Posts tab lit while the user is on the scroll feed at
* /u/<handle>/posts (a sibling route to /u/<handle>). */
activeFor?: string[];
};
export function ProfileTabs({
tabs,
showLabels = false,
}: {
tabs: ProfileTab[];
showLabels?: boolean;
}) {
const pathname = usePathname();
return (
<nav aria-label="Profile sections" className="border-b border-border">
<ul className="flex items-stretch max-w-md mx-auto">
{tabs.map((tab) => {
const active =
pathname === tab.href ||
(tab.activeFor?.includes(pathname) ?? false);
return (
<li key={tab.href} className="flex-1">
<Link
href={tab.href}
aria-current={active ? "page" : undefined}
title={tab.label}
className={cn(
"h-12 -mb-px flex items-center justify-center gap-2 border-b-2",
active
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<span>{tab.icon}</span>
<span
className={cn(
"text-[0.65rem] uppercase tracking-[0.2em] font-semibold",
showLabels ? "" : "sr-only",
)}
>
{tab.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
}

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