Compare commits
24
Commits
feat/go-bff
...
feat/feed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcdd2383c6 | ||
|
|
7bd2952caa | ||
|
|
6b6ce0813e | ||
|
|
3767a3a9b6 | ||
|
|
a22fcf360d | ||
|
|
804cf1ad9a | ||
|
|
30d08c665d | ||
|
|
cc94296b46 | ||
|
|
8cd1d2e724 | ||
|
|
179b7d8862 | ||
|
|
7294d9e15b | ||
|
|
e3df40559b | ||
|
|
9a3056fbd8 | ||
|
|
db8deb232b | ||
|
|
730efb1bb3 | ||
|
|
0106d8cee2 | ||
|
|
d917b3ea7d | ||
|
|
7c07b33e4b | ||
|
|
96a83fe1cb | ||
|
|
befcc44980 | ||
|
|
e45d5eb02c | ||
|
|
09fbcf93ed | ||
|
|
4cbb146bc1 | ||
|
|
bf1ff47d3e |
+152
-252
@@ -15,10 +15,10 @@ Content is sourced from the pre-1980s Vedabase, extracted and indexed into Mongo
|
||||
- **shadcn/ui** (base-nova style, lucide icons) — components: Button, Card, Badge, Tooltip, Input, Drawer
|
||||
- **next-themes** for dark/light/system mode
|
||||
- **vaul** for mobile drawer navigation
|
||||
- **Prisma v6** with MongoDB (provider `prisma-client-js`)
|
||||
- **MongoDB** native driver (`mongodb` package) for seeding (Prisma requires replica set for writes)
|
||||
- **Go BFF API** — all data fetched via `lib/bff.ts` HTTP client calling the Go BFF server (no direct database access)
|
||||
- **framer-motion** for audio player animations (spring enter/exit, minimize/expand transitions, audio wave bars)
|
||||
- **Typesense** (via BFF) — typo-tolerant full-text search across all content
|
||||
- **Python 3** with BeautifulSoup4 + `indic-transliteration` for HTML extraction scripts
|
||||
- **Database**: MongoDB at `DATABASE_URL` env variable with `?authSource=admin`
|
||||
|
||||
---
|
||||
|
||||
@@ -70,9 +70,13 @@ knowledgebase/
|
||||
│ │ ├── place/[place]/page.tsx → Place listing
|
||||
│ │ ├── type/[type]/page.tsx → Type listing (lecture/conversation), year sidebar
|
||||
│ │ └── [slug]/page.tsx → Individual transcription reading page
|
||||
│ ├── search/ → Search page (server + client components)
|
||||
│ │ ├── page.tsx → Server component (URL-driven state)
|
||||
│ │ └── search-content.tsx → Client component (input, filters, results)
|
||||
│ └── api/
|
||||
│ ├── og/route.tsx → Dynamic OG image generator (Noto Sans, Prabhupāda image)
|
||||
│ ├── books/ → Book/verse API routes
|
||||
│ ├── search/route.ts → BFF search proxy (Typesense via /api/v1/search)
|
||||
│ └── glossary/route.ts → Glossary API
|
||||
├── components/
|
||||
│ ├── ui/ → shadcn components (button, card, badge, tooltip, input, drawer)
|
||||
@@ -87,6 +91,7 @@ knowledgebase/
|
||||
│ ├── page-header.tsx → Shared header with ReadingSettings (max-w-5xl)
|
||||
│ ├── reading-settings.tsx → Theme/font/size/justify accessibility panel
|
||||
│ ├── mobile-nav.tsx → Left-slide drawer for mobile sidebar navigation
|
||||
│ ├── header-search.tsx → Header search button with framer-motion expand animation
|
||||
│ ├── site-footer.tsx → Global footer (About + Home links)
|
||||
│ ├── audio-player/ → Site-wide floating audio player
|
||||
│ │ ├── audio-context.tsx → React context + provider, manages <audio> element
|
||||
@@ -96,23 +101,21 @@ knowledgebase/
|
||||
│ ├── logo.tsx → Theme-aware logo component
|
||||
│ └── theme-provider.tsx → next-themes wrapper
|
||||
├── lib/
|
||||
│ ├── db.ts → Prisma client singleton
|
||||
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary)
|
||||
│ ├── bff.ts → BFF API client (typed fetch wrapper: bff<T>(), bffList<T>(), bffOrThrow<T>())
|
||||
│ ├── api.ts → Core data access (books, verses, prose chapters, glossary) — calls BFF API
|
||||
│ ├── books.ts → Generic book data layer (replaces 29 individual book files)
|
||||
│ ├── constants.ts → Shared constants (BOOK_NAMES, CC_LILAS)
|
||||
│ ├── og.ts → OG image metadata builders (ogForVerse, ogForLetter, etc.)
|
||||
│ ├── text-highlight.ts → Text fragment encode/decode, highlight matching
|
||||
│ ├── source-reference.ts → Source reference from URL (for copy attribution)
|
||||
│ ├── disciples.ts → Disciples data access (by letter, place, year, search)
|
||||
│ ├── letters.ts → Letters data access (by year, recipient, location)
|
||||
│ ├── transcriptions.ts → Transcriptions data access (reads from Prisma/MongoDB)
|
||||
│ ├── disciples.ts → Disciples data access — calls BFF API
|
||||
│ ├── letters.ts → Letters data access (by year, recipient, location) — calls BFF API
|
||||
│ ├── transcriptions.ts → Transcriptions data access — calls BFF API
|
||||
│ ├── utils.ts → cn() helper
|
||||
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.)
|
||||
│ ├── other-documents.ts → Other Documents data access
|
||||
│ ├── journals.ts → Journals data access
|
||||
│ └── magazines.ts → Magazines data access
|
||||
├── prisma/
|
||||
│ └── schema.prisma → All models (Verse, BookMeta, ProseChapter, Essay, Letter,
|
||||
│ GlossaryEntry, Disciple, Transcription, etc.)
|
||||
│ ├── [book].ts → Per-book data access layers (nod, tlc, iso, noi, ej, ek, etc.) — calls BFF API
|
||||
│ ├── other-documents.ts → Other Documents data access — calls BFF API
|
||||
│ ├── journals.ts → Journals data access — calls BFF API
|
||||
│ └── magazines.ts → Magazines data access — calls BFF API
|
||||
└── public/
|
||||
├── fonts/Noto_Sans/static/ → NotoSans-Regular.ttf + NotoSans-Bold.ttf (OG images)
|
||||
├── images/ → OG card image (Prabhupāda)
|
||||
@@ -132,232 +135,50 @@ extractor/ → Extraction & seeding tools (not deployed)
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (Prisma + MongoDB)
|
||||
## Data Architecture
|
||||
|
||||
```prisma
|
||||
type Synonym {
|
||||
word String
|
||||
meanings String[]
|
||||
}
|
||||
The knowledgebase **never connects to MongoDB directly**. All data is served by the **Go BFF API** server.
|
||||
|
||||
model Verse {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String // "bg" | "sb" | "cc" | "sb1962" | "iso" | "noi"
|
||||
division String? // null for BG/SB1962/ISO/NOI; canto for SB; lila for CC
|
||||
chapter Int
|
||||
verse Int
|
||||
verseEnd Int?
|
||||
reference String
|
||||
bookTitle String
|
||||
devanagari String
|
||||
bengali String? // only CC has Bengali
|
||||
transliteration String
|
||||
synonyms Synonym[]
|
||||
translation String
|
||||
purport String[]
|
||||
### BFF Server
|
||||
|
||||
@@unique([book, division, chapter, verse])
|
||||
@@index([book])
|
||||
@@index([book, division])
|
||||
@@index([book, division, chapter])
|
||||
@@map("verses")
|
||||
}
|
||||
- **URL**: Configured via `BFF_URL` environment variable (default: `http://localhost:8080`)
|
||||
- The BFF reads from MongoDB — the knowledgebase frontend has no database driver or connection string
|
||||
- All API endpoints live under `/api/v1/...` on the BFF server
|
||||
|
||||
model BookMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
### BFF Client (`lib/bff.ts`)
|
||||
|
||||
@@map("book_meta")
|
||||
}
|
||||
The typed fetch wrapper provides three helpers:
|
||||
|
||||
model ProseChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
volume Int // 0 = no volumes (sentinel for non-volume books)
|
||||
chapter Int? // null for intro segments
|
||||
title String
|
||||
type String // "chapter" | "intro-segment" | "conclusion"
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
- **`bff<T>(path, options?)`** — Fetch JSON from the BFF. Returns `null` on 404, throws `BffError` on other failures.
|
||||
- **`bffList<T>(path, options?)`** — Fetch a list. Returns `[]` on 404 (instead of null).
|
||||
- **`bffOrThrow<T>(path, options?)`** — Fetch JSON, throwing on 404. Use when a missing resource should be an error.
|
||||
|
||||
@@unique([book, volume, slug])
|
||||
@@index([book])
|
||||
@@index([book, volume])
|
||||
@@index([book, volume, type])
|
||||
@@map("prose_chapters")
|
||||
}
|
||||
Options include `params` (query parameters), `revalidate` (Next.js ISR seconds, default 60), and `tags` (cache tags).
|
||||
|
||||
model Essay {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
slug String @unique
|
||||
title String
|
||||
author String
|
||||
year Int?
|
||||
group String? // e.g., "two-essays"
|
||||
order Int
|
||||
paragraphs String[]
|
||||
### Response Envelope
|
||||
|
||||
@@index([group])
|
||||
@@map("essays")
|
||||
}
|
||||
BFF responses use a `{ success: true, data: ... }` envelope. The `bff()` client auto-unwraps this — callers receive the inner `data` value directly.
|
||||
|
||||
model OtherDocumentMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
### Case Mapping
|
||||
|
||||
@@map("other_document_meta")
|
||||
}
|
||||
The BFF uses **snake_case** JSON field names (Go convention). Each frontend data layer file (`lib/letters.ts`, `lib/transcriptions.ts`, `lib/books.ts`, etc.) maps BFF responses to **camelCase** TypeScript types used by React components.
|
||||
|
||||
model OtherDocumentChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
chapter Int
|
||||
title String
|
||||
type String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
### Data Layer Files
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@map("other_document_chapters")
|
||||
}
|
||||
Individual `lib/*.ts` files are thin wrappers that:
|
||||
1. Call a BFF endpoint via `bff()` / `bffList()` / `bffOrThrow()`
|
||||
2. Map the snake_case response to camelCase frontend types
|
||||
3. Export typed functions consumed by Next.js server components
|
||||
|
||||
model JournalMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
|
||||
@@map("journal_meta")
|
||||
}
|
||||
|
||||
model JournalChapter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
chapter Int
|
||||
title String
|
||||
type String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@map("journal_chapters")
|
||||
}
|
||||
|
||||
model MagazineMeta {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String @unique
|
||||
data Json
|
||||
|
||||
@@map("magazine_meta")
|
||||
}
|
||||
|
||||
model MagazineIssue {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
book String
|
||||
volume Int
|
||||
part Int
|
||||
title String
|
||||
slug String
|
||||
order Int
|
||||
paragraphs String[]
|
||||
paragraphsJson String? // JSON string fallback for large issues (BSON wire protocol workaround)
|
||||
|
||||
@@unique([book, slug])
|
||||
@@index([book])
|
||||
@@index([book, volume])
|
||||
@@map("magazine_issues")
|
||||
}
|
||||
|
||||
model Letter {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
slug String @unique
|
||||
recipients String[] // array of individual recipient names
|
||||
recipientDisplay String // original combined string for display
|
||||
date String?
|
||||
dateRef String?
|
||||
year Int?
|
||||
month Int?
|
||||
day Int?
|
||||
location String?
|
||||
salutation String?
|
||||
paragraphs String[]
|
||||
|
||||
@@index([year])
|
||||
@@index([recipients])
|
||||
@@index([location])
|
||||
@@index([year, month])
|
||||
@@map("letters")
|
||||
}
|
||||
|
||||
model GlossaryEntry {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
term String
|
||||
definition String
|
||||
definitionHtml String
|
||||
|
||||
@@unique([term])
|
||||
@@map("glossary")
|
||||
}
|
||||
|
||||
model Disciple {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
initiatedName String
|
||||
suffix String // "Dasa" | "Devi Dasi" | "Sw"
|
||||
initiationDate DateTime? // 1st of the month (exact day unknown unless verified)
|
||||
initiationPlace String?
|
||||
karmiName String? // pre-initiation (birth) name
|
||||
avatar String? // URL to profile photo
|
||||
about String? // biographical notes
|
||||
tags String[]
|
||||
isVerified Boolean @default(false)
|
||||
|
||||
@@index([initiatedName])
|
||||
@@index([initiationPlace])
|
||||
@@index([initiationDate])
|
||||
@@index([suffix])
|
||||
@@index([tags])
|
||||
@@map("disciples")
|
||||
}
|
||||
|
||||
type ContentBlock {
|
||||
text String
|
||||
format String @default("paragraph")
|
||||
alignment String @default("left")
|
||||
speaker String?
|
||||
}
|
||||
|
||||
model Transcription {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
slug String @unique
|
||||
name String
|
||||
type String // "lecture" | "conversation"
|
||||
date String?
|
||||
year Int?
|
||||
month Int?
|
||||
day Int?
|
||||
place String?
|
||||
context String?
|
||||
reference String?
|
||||
summary String?
|
||||
content ContentBlock[]
|
||||
tags String[]
|
||||
participants String[]
|
||||
language String @default("en")
|
||||
source String?
|
||||
durationMinutes Int?
|
||||
audioUrl String?
|
||||
isPublished Boolean @default(false)
|
||||
notes String?
|
||||
|
||||
@@map("transcriptions")
|
||||
}
|
||||
```
|
||||
Key data layers and their BFF endpoint prefixes:
|
||||
- `lib/api.ts` → `/api/v1/books/*`, `/api/v1/glossary/*` — Books, verses, prose chapters, glossary
|
||||
- `lib/books.ts` → `/api/v1/books/{code}/*` — Generic book data layer (replaces 29 individual per-book files)
|
||||
- `lib/letters.ts` → `/api/v1/letters/*` — Letters (by year, recipient, location)
|
||||
- `lib/transcriptions.ts` → `/api/v1/transcriptions/*` — Transcriptions (by year, place, type)
|
||||
- `lib/disciples.ts` → `/api/v1/disciples/*` — Disciples directory
|
||||
- `lib/other-documents.ts` → `/api/v1/other-documents/*` — Other Documents
|
||||
- `lib/journals.ts` → `/api/v1/journals/*` — Journals
|
||||
- `lib/magazines.ts` → `/api/v1/magazines/*` — Magazines
|
||||
|
||||
---
|
||||
|
||||
@@ -503,7 +324,7 @@ Seed script: `npx tsx scripts/seed-disciples.ts`
|
||||
**Schema notes**:
|
||||
- `recipients` is an array of individual names (e.g., `["Mukunda", "Janaki"]`)
|
||||
- `recipientDisplay` is the original combined string (e.g., `"Mukunda, Janaki"`)
|
||||
- Searching for a recipient uses Prisma `{ has: name }` to match any letter where that person appears
|
||||
- Searching for a recipient filters via the BFF endpoint (matches any letter where that person appears)
|
||||
- Multi-recipient groups are split by `,`, `;`, ` and `, ` & `
|
||||
- Trailing punctuation (`:`, `,`, `.`) cleaned from names
|
||||
|
||||
@@ -518,9 +339,7 @@ Seed script: `npx tsx scripts/seed-disciples.ts`
|
||||
- **Audio**: 99.1% have linked audio recordings from Cloudflare R2 (`audioUrl` field)
|
||||
- **Empty content**: 30 transcriptions (Hindi/Bengali lectures) have no English text but may have audio
|
||||
|
||||
Data layer: `lib/transcriptions.ts` (reads from Prisma/MongoDB, NOT local JSON files)
|
||||
|
||||
Seed script: `cd knowledgebase && npx tsx scripts/seed-transcriptions.ts`
|
||||
Data layer: `lib/transcriptions.ts` (calls BFF API, NOT local JSON files or direct MongoDB)
|
||||
|
||||
### Other
|
||||
|
||||
@@ -716,11 +535,17 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
|
||||
### Reading Settings (accessibility)
|
||||
- **Theme**: Light / Dark / System toggle
|
||||
- **Palette**: 3 light + 3 dark colour palettes, each selectable from a swatch grid in the settings panel
|
||||
- Light: **Aged Parchment** (warm cream/saffron — default), **Tulasī Garden** (sage green/emerald), **Yamunā Mist** (sky-blue/cerulean)
|
||||
- Dark: **Ancient Manuscript** (warm brown/saffron — default), **Nīlamaṇi** (deep navy/sky-blue — Kṛṣṇa's "blue jewel"), **Vraja Twilight** (deep wine/rose-red)
|
||||
- Swatches in the settings panel show only the palettes for the currently resolved theme (uses `resolvedTheme` from next-themes, so System mode resolves correctly)
|
||||
- Each palette defines a full set of CSS variables (background, foreground, primary, card, muted, accent, border, etc.) so the entire UI adapts cohesively
|
||||
- Palette CSS lives in `app/globals.css` under `:root:not(.dark)[data-light-palette="..."]` and `.dark[data-dark-palette="..."]` selectors. The `:not(.dark)` scoping prevents light-palette attribute rules from overriding `.dark` (an attribute selector has higher specificity than a plain class selector)
|
||||
- **Serif Font**: Toggle to switch reading content to Georgia/serif
|
||||
- **Font Size**: Slider (XS → XXL) using CSS `zoom` on `main` and `aside`
|
||||
- **Justify Text**: Toggle for justified text alignment on reading content
|
||||
- Settings persist in `localStorage` under `spsays_reading_prefs`
|
||||
- Applied via CSS classes on `<html>` element
|
||||
- Settings persist in `localStorage` under `spsays_reading_prefs` (now includes `lightPalette` and `darkPalette` fields)
|
||||
- Applied via CSS classes (`reading-serif`, `reading-fs-*`, `reading-justified`) AND data attributes (`data-light-palette`, `data-dark-palette`) on `<html>` element
|
||||
|
||||
### Mobile Navigation
|
||||
- Left-slide drawer (vaul) triggered by a menu button next to the Previous button
|
||||
@@ -728,14 +553,80 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
- Shows the full sidebar navigation grouped by section
|
||||
- Implemented in all 24+ reader pages via shared `MobileNav` component
|
||||
|
||||
### Page Header (`components/page-header.tsx`)
|
||||
- Shared client component used on every page — wraps the title/breadcrumb area and the right-side actions (search button + reading settings)
|
||||
- **Mobile layout** uses CSS `float-right` on the actions block so the title text genuinely wraps around them on the first line and uses the **full container width** on every subsequent line. (CSS `padding-right` was previously used but applies the same gap to every line, leaving an empty column down the right edge.)
|
||||
- When the header search is expanded on mobile, the actions block drops the float, the logo/title area is hidden via `max-md:hidden`, and the search bar fills the full header width edge-to-edge (no `ml-2` margin in the expanded state)
|
||||
- **Desktop layout** is unchanged — standard `md:flex md:items-start md:justify-between md:gap-4` with the actions block on the right (`md:order-1 md:shrink-0`)
|
||||
- Reads a sessionStorage flag (`spsays_from_search`) on first render via a lazy `useState` initialiser; if set, renders a “← Back to search results” button that calls `router.back()` and **hides the page's own first-child `<Link>` breadcrumb** via `[&>a:first-child]:hidden` so there's never a double-arrow stack
|
||||
- The lazy initialiser also clears the flag on read (one-shot), so navigating from the result page to a subsequent page (e.g. next verse) doesn't show the search return button again
|
||||
|
||||
### Search
|
||||
|
||||
**Search** (`/search` page) — Typo-tolerant, diacritic-insensitive full-text search across all content.
|
||||
|
||||
- **Powered by Typesense** via the BFF (`/api/v1/search` proxied through `/api/search`)
|
||||
- **9 indexed content types** combined into 8 user-facing filters: Books (verses + prose), Letters, Transcriptions, Glossary, Essays, Journals, Magazines, Other Documents
|
||||
- **Books filter** combines verses and prose into one tab (sends `type=verses,prose` to the BFF)
|
||||
- **Multi-select filters** — clicking multiple type tabs combines them (e.g., Books + Letters); clicking "All" clears all filters
|
||||
- **URL state** — `q`, `type`, and `page` are synced to the URL (e.g. `/search?q=foo&type=books,letters&page=2`), so any search is shareable and bookmarkable
|
||||
- **Pagination** — appears both above the results (compact: prev/next + page count) and below them
|
||||
- **Result cards** — the entire card is a clickable `Link` that navigates to the source page
|
||||
- **Header search** — `HeaderSearchButton` on every page header expands into a search input on click; the `/` keyboard shortcut opens the search input from any page
|
||||
- **300ms debounce** on input
|
||||
- **Highlighted snippets** — matched text wrapped in `<mark>` tags
|
||||
- **Typo tolerance** — "Krishna", "Krsna", "Krishn" all find Kṛṣṇa results
|
||||
- **Diacritic insensitivity** — `_ascii` shadow fields indexed alongside originals (e.g., "Yashodanandana" matches "Yaśodanandana")
|
||||
- **Prefix matching** — partial words like "sarv dharman parit" match `sarva-dharmān parityajya`
|
||||
- **Hyphenated terms** — every collection (not just verses) has `token_separators: ["-"]`, so `Dīkṣā-guru` indexes as two tokens (`dīkṣā` + `guru`) and a query for "diksa guru" matches it cleanly
|
||||
- **Stopwords** — a server-side Typesense stopwords set (`common`) strips conversational tokens like `letter`, `to`, `of`, `essay`, `magazine`, etc. from queries so they don't drown out specific intent. e.g. "letter to tusta krsna" effectively searches for "tusta krsna" and surfaces actual letters to that recipient instead of any letter whose body contains "letter" + "to"
|
||||
- **Exact > prefix > typo ranking** — `prioritize_exact_match=true` + `prioritize_token_position=true` so "karma" outranks "karmic", and exact recipient matches outrank typo matches
|
||||
- **Glossary results** — glossary pages read `?term=` from the URL so a search result lands on the correct term (with diacritic-insensitive matching)
|
||||
- **Defensive null handling** — the frontend always treats a null `results` payload as an empty array
|
||||
- Files: `app/search/page.tsx`, `app/search/search-content.tsx`, `app/api/search/route.ts`, `components/header-search.tsx`
|
||||
|
||||
**Header search behaviour.** When the user submits a search from the inline header input, `HeaderSearchButton` automatically appends a `type` query param matching the section they're currently in (via `usePathname()`):
|
||||
|
||||
| Current path | Search URL gets | Active tab |
|
||||
|---|---|---|
|
||||
| `/books/**` | `type=books` | Books |
|
||||
| `/letters/**` | `type=letters` | Letters |
|
||||
| `/transcriptions/**` | `type=transcriptions` | Transcriptions |
|
||||
| `/essays/**` | `type=essays` | Essays |
|
||||
| `/glossary/**` | `type=glossary` | Glossary |
|
||||
| `/journals/**` | `type=journals` | Journals |
|
||||
| `/magazines/**` | `type=magazines` | Magazines |
|
||||
| `/other-documents/**` | `type=other_documents` | Other Documents |
|
||||
| `/`, `/search`, `/about` | no `type` param | All |
|
||||
|
||||
**Header search expand UX.** The collapsed icon expands into a 400px-wide bar on desktop (capped via `md:max-w-100`, animated to `width: 100%` of its container by framer-motion) and a full-width bar on mobile. The bar has a “Close” X on the left, the text input in the middle, and a primary-coloured “Search ⏎” submit button on the right — the `CornerDownLeft` icon hints that Enter also submits. Pressing Esc collapses. Spring animation on enter, instant collapse (no exit animation, no AnimatePresence — the previous use of `AnimatePresence mode="popLayout"` caused a backwards-shrink flash because `popLayout` pulled the exiting element out of flow and ran a layout-level FLIP).
|
||||
|
||||
**Back to search results.** When a user clicks a result card, `ResultCard`'s `onClick` writes a one-shot `spsays_from_search` flag to sessionStorage. On the destination page, `PageHeader` reads (and consumes) this flag in a lazy `useState` initialiser; if present, it replaces the page's own breadcrumb link with a “← Back to search results” button that calls `router.back()`. Client-side navigation preserves `AudioPlayerProvider` so audio playback continues uninterrupted.
|
||||
|
||||
**Search-result highlight.** Each result card builds its destination URL through `buildSearchResultUrl(result.url, result.snippet)` from `lib/text-highlight.ts`. This extracts the text inside the snippet's `<mark>` tags (from first mark to last mark, HTML stripped), encodes it via the existing `encodeTextFragment()` helper, and appends it as `?hl=...`. The destination page's existing `TextHighlighter` then finds and highlights the matched phrase — reusing the exact same `?hl=` infrastructure as share links, with no changes to `TextHighlighter` itself.
|
||||
|
||||
**Search relevance.** Search is tuned to find verses where the verse text itself contains the query, not commentaries that cite it. Verse purports are intentionally excluded from the default search so results stay focused on primary sources. In practice this means searching for `sarva dharman parityajya` returns BG 18.66 first (the actual verse) rather than the many commentaries that quote it.
|
||||
|
||||
### Per-Page Filters
|
||||
- Books page: diacritic-aware search (typing "Krsna" matches "Kṛṣṇa")
|
||||
- Glossary page: full-text search across terms and definitions
|
||||
- Uses Unicode NFD normalization + manual IAST character stripping
|
||||
|
||||
### Color Palette
|
||||
- **Light mode ("Aged Parchment")**: Warm cream backgrounds, deep sepia text, saffron/ochre primary
|
||||
- **Dark mode ("Ancient Manuscript")**: Deep warm brown backgrounds, parchment text, golden saffron primary
|
||||
### Color Palettes
|
||||
|
||||
Three themes per mode, all named after concepts from the Vedic tradition. Each defines a complete set of CSS variables (background, foreground, primary, card, muted, accent, border, ring, sidebar variants, charts) for cohesive styling site-wide.
|
||||
|
||||
**Light palettes**
|
||||
- **Aged Parchment** (default) — warm cream backgrounds, deep sepia text, saffron/ochre primary
|
||||
- **Tulasī Garden** — sage-green backgrounds, deep forest text, emerald primary; inspired by the sacred Tulasī plant
|
||||
- **Yamunā Mist** — soft blue backgrounds, deep teal text, cerulean primary; inspired by the sacred Yamunā river
|
||||
|
||||
**Dark palettes**
|
||||
- **Ancient Manuscript** (default) — deep warm brown backgrounds, parchment text, golden saffron primary
|
||||
- **Nīlamaṇi** — deep navy backgrounds, soft moonlight text, sky-blue primary; “blue jewel,” one of Kṛṣṇa's epithets
|
||||
- **Vraja Twilight** — deep wine backgrounds, warm cream text, rose-red primary; the crimson dusk over Vraja
|
||||
|
||||
Palettes are switched independently of theme: the user can pick their preferred light palette AND their preferred dark palette — the right one takes effect automatically when next-themes toggles between modes. Selection happens in `components/reading-settings.tsx` via a 3-up grid of swatch buttons (each swatch is a paint-chip-style square with the palette's background colour plus a small primary-coloured dot in the corner).
|
||||
|
||||
### Text Selection Toolbar
|
||||
- Global floating toolbar appears on text selection across all reading pages (except RTFs)
|
||||
@@ -757,6 +648,8 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
- Server-side `extractMatchedText()` searches paragraphs for OG image quote extraction
|
||||
- Case-insensitive matching for both DOM highlighting and server-side text extraction
|
||||
- Cross-paragraph matching via position mapping (spaces inserted at element boundaries)
|
||||
- **HTML-entity regex must be strict.** `extractMatchedText()` strips HTML entities from paragraph strings before searching. The regex matches only real entity shapes — named (`&`), decimal (`'`), and hex (`'`) — not the loose `&[^;]+;` pattern, which would pair a stray `&` (e.g. "24 & 25 Oct") with a far-away `;` (e.g. "my beloved son;") and eat everything between them, breaking OG quote extraction for any selection that spans such a stray ampersand. The browser-side `findAndHighlightText` is unaffected because it walks DOM text nodes that the browser has already decoded.
|
||||
- `buildSearchResultUrl(resultUrl, snippetHtml)` extracts the matched text from a search snippet's `<mark>` tags and appends `?hl=...` to the result URL so the destination page highlights the match — same infrastructure as share links.
|
||||
|
||||
### Dynamic OG Images
|
||||
- `/api/og` route generates 1200×630 PNG images using `next/og` (Satori)
|
||||
@@ -771,7 +664,10 @@ SSR and DS have parent chapters with sub-chapters stored as individual `prose_ch
|
||||
A site-wide floating audio player for transcription recordings (and future music library). Built with framer-motion for animations.
|
||||
|
||||
- **Desktop**: Centered floating bar (max-width 680px) with cover image, track info, transport controls (prev/play/next), volume slider, source link, close button
|
||||
- **Mobile**: Auto-minimizes to a floating pill (cover image with circular progress ring and audio wave animation) after 8 seconds of inactivity. Tap to expand. Full player shows two rows: track info row + centered transport controls
|
||||
- **Mobile**: Full player shows two rows: track info row + centered transport controls
|
||||
- **Auto-minimize**: After 8 seconds of inactivity on **both mobile and desktop**, the player auto-minimizes to a floating pill. The minimized pill shows cover image with a circular progress ring and audio wave animation. Tap/click to expand.
|
||||
- **X button behavior**: Minimizes the player if audio is playing; stops playback entirely if audio is paused
|
||||
- **Clicking outside the player**: Minimizes it (does not stop playback)
|
||||
- **Z-index**: `z-40` — below text selection toolbar (z-80), drawers (z-90), modals (z-100)
|
||||
- **Hidden when selection toolbar is active** on mobile to avoid overlap
|
||||
- **Forced dark surface** (`bg-[#1c1c1e]`) for accessibility contrast regardless of theme
|
||||
@@ -799,30 +695,32 @@ The `AudioPlayerProvider` wraps the entire app in `layout.tsx`. The `FloatingPla
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
DATABASE_URL=mongodb://user:pass@host:port/spsays?authSource=admin
|
||||
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org
|
||||
BFF_URL=http://localhost:8080 # Go BFF API server URL (default: http://localhost:8080)
|
||||
ALLOW_RTF=true # Enable RTF viewer (development only)
|
||||
NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org # Public site URL for OG images
|
||||
```
|
||||
|
||||
> No new vars are needed for the frontend search feature — search goes through the BFF, which has `TYPESENSE_URL` and `TYPESENSE_API_KEY` configured server-side.
|
||||
|
||||
---
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Development (from knowledgebase/)
|
||||
npm run dev
|
||||
# 1. Start the Go BFF server (required — all data comes from the BFF)
|
||||
cd spsays-bff && go run cmd/api/main.go # Starts on :8080 by default
|
||||
|
||||
# Prisma
|
||||
npx prisma generate # Regenerate Prisma client after schema changes
|
||||
# 2. Development (from knowledgebase/)
|
||||
npm run dev # Next.js dev server with Turbopack
|
||||
|
||||
# Build
|
||||
# 3. Build
|
||||
npx next build
|
||||
|
||||
# Extraction & seeding scripts are in extractor/ — see extractor/scripts/
|
||||
|
||||
# BFF API server (from ../spsays-bff/)
|
||||
make run # Build + start Go API on :8080
|
||||
```
|
||||
|
||||
> **Note**: No `npx prisma generate`, `npx prisma db push`, or MongoDB connection string needed in the frontend. The BFF server must be running for any page to load data.
|
||||
|
||||
---
|
||||
|
||||
## Verse JSON Schema (BG/SB/CC/SB1962/ISO/NOI)
|
||||
@@ -946,7 +844,7 @@ Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **MongoDB BSON wire corruption**: The MongoDB server occasionally corrupts large BSON documents. Workaround: seed using `insertOne` + `$push` for paragraphs one at a time.
|
||||
1. **BFF dependency** — The knowledgebase requires the Go BFF server to be running; all pages will fail to load data if the BFF is down.
|
||||
|
||||
2. **SB verse numbering**: 5 duplicate verse reference tags in the HTML. Fixed by using the TEXT N header as authoritative.
|
||||
|
||||
@@ -956,10 +854,12 @@ Content block formats: `"paragraph"` (body text), `"verse"` (centered italic), `
|
||||
|
||||
5. **CSS zoom for font scaling**: `zoom` property works in Chrome/Safari/Edge but may not work in older Firefox (< 126). A `transform: scale()` fallback is provided via `@supports not (zoom: 1.1)`.
|
||||
|
||||
6. **Password protection requires ACCESS_KEY at runtime**: The `PasswordGate` component checks the server at runtime via `/api/verify-access`. The env var must be available when the server is running, not just at build time.
|
||||
6. **Transcription BSON fallback** — 1 document stores content as `contentJson` string due to BSON wire protocol limits; `getTranscription()` transparently parses it
|
||||
|
||||
7. **Transcription BSON fallback** — 1 document stores content as `contentJson` string due to BSON wire protocol limits; `getTranscription()` transparently parses it
|
||||
7. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
|
||||
|
||||
8. **Empty transcriptions** — 30 entries have no English text content (Hindi/Bengali recordings); the UI shows "No transcription available" with the audio player still accessible
|
||||
8. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
|
||||
|
||||
9. **Audio player z-index** — Player is hidden when text selection toolbar is active on mobile to avoid overlap
|
||||
9. **Search depends on Typesense** — Search depends on Typesense being available via the BFF; if Typesense is down, search will return empty results but the rest of the site is unaffected.
|
||||
|
||||
10. **Palette FOUC on first load** — Palette data attributes are applied via the `applyClasses` effect after first paint. A user with a non-default palette will see a brief flash of the default palette before their preference kicks in. Could be fixed by injecting an inline script in `layout.tsx` that reads `localStorage` before hydration (next-themes does this for the `.dark` class). Not yet implemented.
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+222
-1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+43
-3
@@ -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
|
||||
>
|
||||
<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 />
|
||||
<SiteFooter />
|
||||
<PageBookmarks />
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</AudioPlayerProvider>
|
||||
</AuthGateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
|
||||
+70
-266
@@ -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 & 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 “Krishna The Reservoir Of
|
||||
Pleasure” and “Who Is Crazy?” — timeless
|
||||
introductions to the science of Kṛṣṇa consciousness.
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
{/* 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's personal correspondence — over 6,800
|
||||
letters to disciples, Godbrothers, government officials, and
|
||||
well-wishers. Browse by year, recipient, or location.
|
||||
{!authed ? (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
No account?{" "}
|
||||
<Link href="/signup" className="text-foreground hover:underline">
|
||||
Create one
|
||||
</Link>{" "}
|
||||
— it's free.
|
||||
</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 & conversations
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</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 & journals
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Śrīla Prabhupāda'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 & 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'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 & 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't fit neatly into the books or essays
|
||||
collections.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { SearchContent } from "./search-content";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return buildOgMeta({
|
||||
title: "Search",
|
||||
detail: "Search across all content",
|
||||
quote:
|
||||
"Search across Śrīla Prabhupāda's books, letters, lectures, conversations, and more.",
|
||||
});
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return <SearchContent />;
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
“Krsna” finds “Kṛṣṇa.”
|
||||
</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 “{debouncedQuery}”.
|
||||
<br />
|
||||
<span className="text-sm">
|
||||
Try a different search term or content type.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!isLoading && effectiveData && effectiveData.results.length > 0 && (
|
||||
<>
|
||||
{/* Results count + top pagination */}
|
||||
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{effectiveData.total.toLocaleString()}{" "}
|
||||
{effectiveData.total === 1 ? "result " : "results "} for
|
||||
“
|
||||
{effectiveData.query}”
|
||||
</p>
|
||||
{effectiveData.total_pages > 1 && (
|
||||
<PaginationControls
|
||||
page={effectiveData.page}
|
||||
totalPages={effectiveData.total_pages}
|
||||
onPageChange={handlePageChange}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result list */}
|
||||
<div className="space-y-3">
|
||||
{effectiveData.results.map((result, i) => (
|
||||
<ResultCard
|
||||
key={`${result.collection}-${result.id || i}`}
|
||||
result={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom pagination */}
|
||||
{effectiveData.total_pages > 1 && (
|
||||
<div className="mt-8">
|
||||
<PaginationControls
|
||||
page={effectiveData.page}
|
||||
totalPages={effectiveData.total_pages}
|
||||
total={effectiveData.total}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination Controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PaginationControls({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
onPageChange,
|
||||
compact = false,
|
||||
}: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total?: number;
|
||||
onPageChange: (page: number) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
{total != null && ` · ${total.toLocaleString()} results`}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result Card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ResultCard({ result }: { result: SearchResult }) {
|
||||
const config = COLLECTION_CONFIG[result.collection] || {
|
||||
label: result.collection,
|
||||
icon: FileStack,
|
||||
color: "bg-muted text-muted-foreground",
|
||||
};
|
||||
const Icon = config.icon;
|
||||
|
||||
// Append `?hl=<matched-text>` so the destination page highlights the snippet
|
||||
// text Typesense matched. Falls back to the original URL if no marks were
|
||||
// present in the snippet.
|
||||
const href = buildSearchResultUrl(result.url, result.snippet);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block group 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ShareNotFound() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-semibold">Link not found</h1>
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
This short link may have expired or never existed.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-primary mt-6 text-sm underline underline-offset-4"
|
||||
>
|
||||
Return to Prabhupāda Says →
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { resolveShortLink } from "@/lib/share";
|
||||
import { buildShareOgMeta } from "@/lib/share-og";
|
||||
import { ogDefault } from "@/lib/og";
|
||||
import { RedirectClient } from "./redirect-client";
|
||||
|
||||
type PageProps = { params: Promise<{ code: string }> };
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const { code } = await params;
|
||||
const fullPath = await resolveShortLink(code);
|
||||
if (!fullPath) {
|
||||
return {
|
||||
title: "Link expired — Prabhupāda Says",
|
||||
robots: { index: false, follow: false },
|
||||
...ogDefault(),
|
||||
};
|
||||
}
|
||||
return buildShareOgMeta(fullPath);
|
||||
}
|
||||
|
||||
export default async function SharePage({ params }: PageProps) {
|
||||
const { code } = await params;
|
||||
const fullPath = await resolveShortLink(code);
|
||||
if (!fullPath) notFound();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Redirecting to your highlight…
|
||||
</p>
|
||||
<noscript>
|
||||
<p className="mt-4 text-sm">
|
||||
JavaScript is disabled.{" "}
|
||||
<a className="underline" href={fullPath}>
|
||||
Continue to the page
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</noscript>
|
||||
<RedirectClient to={fullPath} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Client-side redirect: we deliberately don't use `redirect()` from
|
||||
// next/navigation on the server, because that returns a 307 before the OG
|
||||
// `<head>` is ever served. With `generateMetadata` + a 200 body, social-link
|
||||
// scrapers get the full OG card while real users still get redirected.
|
||||
export function RedirectClient({ to }: { to: string }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace(to);
|
||||
}, [router, to]);
|
||||
return null;
|
||||
}
|
||||
@@ -117,16 +117,16 @@ export default async function TranscriptionPage({
|
||||
<TranscriptionNav prev={prev} next={next} />
|
||||
|
||||
{/* Audio + Participants row */}
|
||||
<div className="w-full flex flex-col md:flex-row items-start md:items-end justify-between gap-4">
|
||||
<div className="w-full flex flex-col md:flex-row items-start md:items-start justify-between gap-4">
|
||||
{/* Participants */}
|
||||
{t.participants.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex pl-2 items-center gap-2 flex-wrap text-sm text-muted-foreground">
|
||||
<div className="">
|
||||
<div className="flex flex-row pl-2 mb-2 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
<span>Participants:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
|
||||
<div className="max-w-full flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
|
||||
{t.participants.map((p, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
@@ -143,7 +143,7 @@ export default async function TranscriptionPage({
|
||||
|
||||
{/* Play Audio button */}
|
||||
{t.audioUrl && (
|
||||
<div className="w-full md:w-auto flex justify-end">
|
||||
<div className="w-full md:w-[25%] flex justify-end">
|
||||
<PlayAudioButton
|
||||
audioUrl={t.audioUrl}
|
||||
title={t.name}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't posted anything yet. Select any passage in the
|
||||
knowledgebase and use the toolbar to share it to your feed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ProfileViewHeader — the read-only header used in view mode on /u/me.
|
||||
* Mirrors the shape of /u/[handle]'s top block so the owner sees the same
|
||||
* thing visitors do, plus an action slot for an "Edit profile" button.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
import type { User } from "@/lib/auth";
|
||||
|
||||
export function ProfileViewHeader({
|
||||
user,
|
||||
postCount,
|
||||
actionSlot,
|
||||
}: {
|
||||
user: User;
|
||||
postCount: number;
|
||||
actionSlot?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-col sm:flex-row sm:items-center gap-5 sm:gap-6">
|
||||
<Avatar src={user.avatar_url} name={user.name} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight truncate">
|
||||
{user.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
@{user.handle} ·{" "}
|
||||
<span className="tabular-nums">{postCount}</span> post
|
||||
{postCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Joined {new Date(user.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
{actionSlot ? <div className="shrink-0">{actionSlot}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ src, name }: { src: string | null; name: string }) {
|
||||
if (src) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={88}
|
||||
height={88}
|
||||
className="size-20 sm:size-22 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 sm:size-22 rounded-full bg-primary/15 text-primary flex items-center justify-center text-2xl font-semibold ring-1 ring-foreground/10">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Settings, Sun, Moon, Monitor } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,18 +18,88 @@ function useMounted() {
|
||||
);
|
||||
}
|
||||
|
||||
type LightPalette = "aged-parchment" | "tulasi-garden" | "yamuna-mist";
|
||||
type DarkPalette = "ancient-manuscript" | "nilamani" | "vraja-twilight";
|
||||
|
||||
type ReadingPrefs = {
|
||||
serifFont: boolean;
|
||||
fontSize: string;
|
||||
justified: boolean;
|
||||
lightPalette: LightPalette;
|
||||
darkPalette: DarkPalette;
|
||||
};
|
||||
|
||||
const DEFAULT_PREFS: ReadingPrefs = {
|
||||
serifFont: false,
|
||||
fontSize: "base",
|
||||
justified: false,
|
||||
lightPalette: "aged-parchment",
|
||||
darkPalette: "ancient-manuscript",
|
||||
};
|
||||
|
||||
// Preview swatches use approximations of each palette's background
|
||||
// and primary colours — the real colours come from the CSS variables.
|
||||
const LIGHT_PALETTES: {
|
||||
id: LightPalette;
|
||||
name: string;
|
||||
bg: string;
|
||||
primary: string;
|
||||
}[] = [
|
||||
// Warm cream + saffron — the original
|
||||
{
|
||||
id: "aged-parchment",
|
||||
name: "Aged Parchment",
|
||||
bg: "#f5ecd7",
|
||||
primary: "#b88940",
|
||||
},
|
||||
// Sage green background + deep emerald — distinctly GREEN
|
||||
{
|
||||
id: "tulasi-garden",
|
||||
name: "Tulasī Garden",
|
||||
bg: "#c4dcc1",
|
||||
primary: "#207537",
|
||||
},
|
||||
// Soft blue background + cerulean — distinctly BLUE
|
||||
{
|
||||
id: "yamuna-mist",
|
||||
name: "Yamunā Mist",
|
||||
bg: "#c2d7ec",
|
||||
primary: "#1f6fd6",
|
||||
},
|
||||
];
|
||||
|
||||
const DARK_PALETTES: {
|
||||
id: DarkPalette;
|
||||
name: string;
|
||||
bg: string;
|
||||
primary: string;
|
||||
}[] = [
|
||||
// Warm brown + saffron — the original
|
||||
{
|
||||
id: "ancient-manuscript",
|
||||
name: "Ancient Manuscript",
|
||||
bg: "#2a2519",
|
||||
primary: "#d0a04e",
|
||||
},
|
||||
// Deep navy + sky-blue — distinctly BLUE
|
||||
{
|
||||
id: "nilamani",
|
||||
name: "Nīlamaṇi",
|
||||
bg: "#101a36",
|
||||
primary: "#4a9fff",
|
||||
},
|
||||
// Deep wine + rose-red — distinctly RED/WARM
|
||||
{
|
||||
id: "vraja-twilight",
|
||||
name: "Vraja Twilight",
|
||||
bg: "#2b1217",
|
||||
primary: "#e07252",
|
||||
},
|
||||
];
|
||||
|
||||
const LIGHT_PALETTE_IDS = LIGHT_PALETTES.map((p) => p.id);
|
||||
const DARK_PALETTE_IDS = DARK_PALETTES.map((p) => p.id);
|
||||
|
||||
const FONT_SIZES = [
|
||||
{ value: "xs", label: "XS" },
|
||||
{ value: "sm", label: "S" },
|
||||
@@ -58,6 +129,12 @@ function loadPrefs(): ReadingPrefs {
|
||||
typeof parsed.justified === "boolean"
|
||||
? parsed.justified
|
||||
: DEFAULT_PREFS.justified,
|
||||
lightPalette: LIGHT_PALETTE_IDS.includes(parsed.lightPalette)
|
||||
? parsed.lightPalette
|
||||
: DEFAULT_PREFS.lightPalette,
|
||||
darkPalette: DARK_PALETTE_IDS.includes(parsed.darkPalette)
|
||||
? parsed.darkPalette
|
||||
: DEFAULT_PREFS.darkPalette,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_PREFS;
|
||||
@@ -86,6 +163,11 @@ function applyClasses(prefs: ReadingPrefs) {
|
||||
|
||||
// Justified
|
||||
root.classList.toggle("reading-justified", prefs.justified);
|
||||
|
||||
// Palettes — always set both attributes so the appropriate one takes
|
||||
// effect when the user toggles light ↔ dark.
|
||||
root.setAttribute("data-light-palette", prefs.lightPalette);
|
||||
root.setAttribute("data-dark-palette", prefs.darkPalette);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
@@ -123,13 +205,88 @@ function Toggle({
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadingSettings() {
|
||||
/** Optional render-prop trigger — lets sidebar/bottom-nav variants compose
|
||||
* their own row layout while reusing ReadingSettings' state + popover. */
|
||||
type TriggerRenderer = (state: {
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
}) => React.ReactNode;
|
||||
|
||||
/** Where the panel renders relative to its trigger.
|
||||
*
|
||||
* - "below-right" (default) — header-style: panel hangs below the
|
||||
* trigger, right-aligned. Works when the trigger sits near the top.
|
||||
* - "above-left" — sidebar-style: panel rises above the trigger and
|
||||
* extends to the right so a bottom-of-sidebar button doesn't push
|
||||
* the popover off the bottom of the viewport. */
|
||||
type Placement = "below-right" | "above-left";
|
||||
|
||||
export function ReadingSettings({
|
||||
renderTrigger,
|
||||
placement = "below-right",
|
||||
onOpenChange,
|
||||
}: {
|
||||
renderTrigger?: TriggerRenderer;
|
||||
placement?: Placement;
|
||||
/** Fires every time the popover opens or closes. Used by the sidebar
|
||||
* to keep the rail expanded for as long as the popover is visible. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
} = {}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs());
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const mounted = useMounted();
|
||||
// When the panel sits next to a sidebar, we render it through a portal
|
||||
// with viewport-fixed coordinates so it (a) escapes the sidebar's
|
||||
// bounds, (b) doesn't shift when the sidebar collapses on mouseleave,
|
||||
// and (c) stays a comfortable gap to the right of the rail.
|
||||
const [portalPos, setPortalPos] = useState<{
|
||||
left: number;
|
||||
bottom: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || placement !== "above-left") {
|
||||
setPortalPos(null);
|
||||
return;
|
||||
}
|
||||
function recompute() {
|
||||
const btn = buttonRef.current;
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
// Anchor by `bottom` (distance from viewport bottom) so the panel
|
||||
// grows upward from the trigger. `left` clears the rail by 16px.
|
||||
const left = rect.right + 16;
|
||||
const bottom = Math.max(8, window.innerHeight - rect.bottom);
|
||||
setPortalPos({ left, bottom });
|
||||
}
|
||||
recompute();
|
||||
// The sidebar width transitions when the rail expands — observe the
|
||||
// trigger so the popover slides along with it instead of staying at
|
||||
// the collapsed-rail x-coordinate.
|
||||
const btn = buttonRef.current;
|
||||
let observer: ResizeObserver | null = null;
|
||||
if (btn && typeof ResizeObserver !== "undefined") {
|
||||
observer = new ResizeObserver(() => recompute());
|
||||
observer.observe(btn);
|
||||
}
|
||||
window.addEventListener("resize", recompute);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", recompute);
|
||||
};
|
||||
}, [open, placement]);
|
||||
|
||||
// Notify caller whenever the popover open state changes. We can't fire
|
||||
// this synchronously inside setOpen because some consumers (e.g. the
|
||||
// sidebar) feed it back into their own state — using useEffect breaks
|
||||
// the render cycle cleanly.
|
||||
useEffect(() => {
|
||||
onOpenChange?.(open);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Apply classes on mount (prefs already loaded via lazy initializer)
|
||||
useEffect(() => {
|
||||
@@ -178,6 +335,14 @@ export function ReadingSettings() {
|
||||
const fontSizeIndex = FONT_SIZES.findIndex((f) => f.value === prefs.fontSize);
|
||||
const currentFontSizeLabel = FONT_SIZES[fontSizeIndex]?.label ?? "M";
|
||||
|
||||
// Show palettes matching the currently visible mode. Falls back to light if
|
||||
// resolvedTheme hasn't been determined yet (e.g. first render).
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const visiblePalettes = isDark ? DARK_PALETTES : LIGHT_PALETTES;
|
||||
const activePaletteId = isDark ? prefs.darkPalette : prefs.lightPalette;
|
||||
const activePaletteName =
|
||||
visiblePalettes.find((p) => p.id === activePaletteId)?.name ?? "";
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
|
||||
@@ -186,23 +351,51 @@ export function ReadingSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const toggle = () => setOpen((v) => !v);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
{renderTrigger ? (
|
||||
renderTrigger({ open, toggle, buttonRef })
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef as React.Ref<HTMLButtonElement>}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Reading settings"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<Settings className="size-5 md:size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<MaybePortal portal={placement === "above-left" && portalPos !== null}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-0 top-full mt-2 w-72 rounded-lg border border-border bg-card shadow-lg z-50 p-4"
|
||||
style={
|
||||
placement === "above-left" && portalPos
|
||||
? {
|
||||
position: "fixed",
|
||||
left: portalPos.left,
|
||||
bottom: portalPos.bottom,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-72 rounded-lg border border-border bg-card shadow-lg p-4",
|
||||
"max-h-[80vh] overflow-y-auto",
|
||||
placement === "above-left" && portalPos
|
||||
? "z-[100]"
|
||||
: placement === "above-left"
|
||||
? "absolute bottom-full left-0 mb-2 z-50"
|
||||
: "absolute top-full right-0 mt-2 z-50",
|
||||
)}
|
||||
>
|
||||
{/* Theme selector */}
|
||||
<div className="mb-4">
|
||||
@@ -235,6 +428,54 @@ export function ReadingSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palette selector — shows light or dark palettes depending on
|
||||
the currently resolved theme. */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Palette
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{activePaletteName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{visiblePalettes.map((p) => {
|
||||
const isActive = activePaletteId === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updatePrefs(
|
||||
isDark
|
||||
? { darkPalette: p.id as DarkPalette }
|
||||
: { lightPalette: p.id as LightPalette },
|
||||
)
|
||||
}
|
||||
aria-label={`Use ${p.name} palette`}
|
||||
aria-pressed={isActive}
|
||||
title={p.name}
|
||||
className={cn(
|
||||
"group relative aspect-square rounded-md transition-all cursor-pointer overflow-hidden",
|
||||
"border-2 outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-border hover:border-foreground/40",
|
||||
)}
|
||||
style={{ backgroundColor: p.bg }}
|
||||
>
|
||||
{/* Primary-color dot in the corner */}
|
||||
<span
|
||||
className="absolute bottom-1.5 right-1.5 size-3 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: p.primary }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Reading
|
||||
@@ -283,7 +524,25 @@ export function ReadingSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MaybePortal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Conditionally render `children` through a React portal to document.body.
|
||||
* When `portal` is false (or SSR), children render in place. We use this
|
||||
* to escape stacking + overflow contexts (e.g. inside the sidebar) without
|
||||
* duplicating the panel JSX. */
|
||||
function MaybePortal({
|
||||
portal,
|
||||
children,
|
||||
}: {
|
||||
portal: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (portal && typeof document !== "undefined") {
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { PSEUDO_COLLECTIONS } from "./shared";
|
||||
import { FolderPlus } from "lucide-react";
|
||||
/**
|
||||
* CollectionsDrawer — "Save to Collection" picker, reused in two flows:
|
||||
*
|
||||
* • Selection toolbar — caller passes `selectedText`. On row click we
|
||||
* POST a new public post first, then add it to the chosen collection.
|
||||
*
|
||||
* • Post card footer — caller passes `existingPostId` (the post is
|
||||
* already saved). We skip the create step and only call addItem.
|
||||
*
|
||||
* Exactly one of `selectedText` or `existingPostId` is meaningful per
|
||||
* mount. Per the product spec: collections always hold posts, every
|
||||
* post is public on the user's feed, and bookmarks are the private save.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { FolderPlus, Check, Plus, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
collections,
|
||||
posts as postsApi,
|
||||
SocialError,
|
||||
type Collection,
|
||||
} from "@/lib/social";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { getSourceReference } from "@/lib/source-reference";
|
||||
|
||||
type Status = "idle" | "saving" | "saved" | "auth" | "limit" | "error";
|
||||
|
||||
export function CollectionsDrawer({
|
||||
open,
|
||||
onClose,
|
||||
isMobile,
|
||||
selectedText,
|
||||
existingPostId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
isMobile: boolean;
|
||||
/** Provided by the selection toolbar — the drawer will create a fresh
|
||||
* post from this text before adding it to a collection. */
|
||||
selectedText?: string;
|
||||
/** Provided by post-card footers — the drawer skips post creation and
|
||||
* attaches the existing post id to the chosen collection. */
|
||||
existingPostId?: string;
|
||||
}) {
|
||||
const [list, setList] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// For the existing-post mode: which collections already contain the
|
||||
// post, so we can disable those rows. Empty for the toolbar mode
|
||||
// (a brand-new post can't belong to anything yet).
|
||||
const [alreadyIn, setAlreadyIn] = useState<Set<string>>(new Set());
|
||||
|
||||
// Per-row save status — keyed by collection id so each row can show its
|
||||
// own "saving / saved" state without blocking the others.
|
||||
const [rowStatus, setRowStatus] = useState<Record<string, Status>>({});
|
||||
const [rowMsg, setRowMsg] = useState<Record<string, string>>({});
|
||||
|
||||
// Inline "new collection" form state.
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newIsPublic, setNewIsPublic] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Fetch the user's collections in parallel with the post→collection
|
||||
// membership lookup so the rows render with the disabled state set
|
||||
// on first paint (no flicker from "Save" → "Saved").
|
||||
const tasks: Promise<unknown>[] = [
|
||||
collections
|
||||
.mine()
|
||||
.then((rows) => setList(rows ?? []))
|
||||
.catch((err) => {
|
||||
if (err instanceof SocialError && err.status === 401) {
|
||||
setError("Log in to use collections.");
|
||||
} else {
|
||||
setError(
|
||||
err instanceof SocialError ? err.message : "Could not load collections.",
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
if (existingPostId) {
|
||||
tasks.push(
|
||||
collections
|
||||
.containingPost(existingPostId)
|
||||
.then((ids) => setAlreadyIn(new Set(ids)))
|
||||
.catch(() => {
|
||||
/* Non-fatal — drawer still works without the disabled hints. */
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setAlreadyIn(new Set());
|
||||
}
|
||||
Promise.all(tasks).finally(() => setLoading(false));
|
||||
}, [open, existingPostId]);
|
||||
|
||||
// Reset transient UI state when drawer closes.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRowStatus({});
|
||||
setRowMsg({});
|
||||
setShowCreate(false);
|
||||
setNewTitle("");
|
||||
setAlreadyIn(new Set());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function saveTo(collectionID: string) {
|
||||
setRowStatus((s) => ({ ...s, [collectionID]: "saving" }));
|
||||
setRowMsg((m) => ({ ...m, [collectionID]: "" }));
|
||||
try {
|
||||
// Two paths:
|
||||
// - existingPostId provided → just attach.
|
||||
// - selectedText provided → create a post first, then attach.
|
||||
let postID = existingPostId ?? null;
|
||||
if (!postID) {
|
||||
const sourceURL = buildHighlightUrl(selectedText ?? "");
|
||||
const ref = getSourceReference();
|
||||
const sourceTitle = ref
|
||||
? ref.detail
|
||||
? `${ref.title} — ${ref.detail}`
|
||||
: ref.title
|
||||
: typeof document !== "undefined"
|
||||
? document.title
|
||||
: "";
|
||||
const post = await postsApi.create({
|
||||
caption: selectedText ?? "",
|
||||
source_url: sourceURL,
|
||||
source_title: sourceTitle,
|
||||
});
|
||||
postID = post.id;
|
||||
}
|
||||
await collections.addItem(collectionID, postID);
|
||||
setRowStatus((s) => ({ ...s, [collectionID]: "saved" }));
|
||||
// Bump the local item_count so the user sees the change without a refetch.
|
||||
setList((rows) =>
|
||||
rows.map((c) =>
|
||||
c.id === collectionID ? { ...c, item_count: c.item_count + 1 } : c,
|
||||
),
|
||||
);
|
||||
setTimeout(onClose, 800);
|
||||
} catch (err) {
|
||||
if (err instanceof SocialError) {
|
||||
const tone: Status =
|
||||
err.status === 401 ? "auth" : err.status === 402 ? "limit" : "error";
|
||||
setRowStatus((s) => ({ ...s, [collectionID]: tone }));
|
||||
setRowMsg((m) => ({ ...m, [collectionID]: err.message }));
|
||||
} else {
|
||||
setRowStatus((s) => ({ ...s, [collectionID]: "error" }));
|
||||
setRowMsg((m) => ({ ...m, [collectionID]: "Save failed." }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createCollection() {
|
||||
const title = newTitle.trim();
|
||||
if (!title) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const c = await collections.create({ title, is_public: newIsPublic });
|
||||
setList((rows) => [c, ...rows]);
|
||||
setNewTitle("");
|
||||
setShowCreate(false);
|
||||
// Auto-save the quote into the new collection so the user gets to the
|
||||
// payoff in one step.
|
||||
void saveTo(c.id);
|
||||
} catch (err) {
|
||||
if (err instanceof SocialError && err.status === 402) {
|
||||
setError(err.message);
|
||||
} else if (err instanceof SocialError && err.status === 401) {
|
||||
setError("Log in to create a collection.");
|
||||
} else {
|
||||
setError(
|
||||
err instanceof SocialError ? err.message : "Could not create collection.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const direction = isMobile ? "bottom" : "right";
|
||||
|
||||
return (
|
||||
@@ -32,47 +204,125 @@ export function CollectionsDrawer({
|
||||
className="bg-black dark:bg-[#1a1a1a] border-white/10"
|
||||
>
|
||||
<DrawerHeader className="text-left border-b border-white/10 pb-4">
|
||||
<DrawerTitle className="text-white">Save to Collection</DrawerTitle>
|
||||
<DrawerTitle className="text-white">Save to collection</DrawerTitle>
|
||||
{selectedText ? (
|
||||
<p className="text-xs text-white/50 mt-1 line-clamp-2">
|
||||
“{selectedText.slice(0, 140)}
|
||||
{selectedText.length > 140 ? "…" : ""}”
|
||||
</p>
|
||||
) : null}
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-1">
|
||||
{PSEUDO_COLLECTIONS.map((col) => (
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-white/60">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-amber-400 px-3 py-2">{error}</p>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && list.length === 0 ? (
|
||||
<p className="text-sm text-white/50 px-3 py-2">
|
||||
No collections yet — create one below.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{list.map((c) => {
|
||||
const st = rowStatus[c.id] ?? "idle";
|
||||
// A row is "already saved" if the BFF told us this post is
|
||||
// already in this collection. We treat that the same as
|
||||
// st === "saved" (button locked, check mark shown), just
|
||||
// with a different sub-line for the user.
|
||||
const alreadySaved = alreadyIn.has(c.id);
|
||||
const locked = alreadySaved || st === "saving" || st === "saved";
|
||||
return (
|
||||
<button
|
||||
key={col.id}
|
||||
onClick={() => {
|
||||
// TODO: Implement save to collection
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
|
||||
key={c.id}
|
||||
onClick={() => saveTo(c.id)}
|
||||
disabled={locked}
|
||||
aria-disabled={locked}
|
||||
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors disabled:hover:bg-transparent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10">
|
||||
<FolderPlus className="size-4 text-white/50" />
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10 shrink-0">
|
||||
<FolderPlus className="size-4 text-white/60" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white/90">
|
||||
{col.name}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">
|
||||
{c.title}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 truncate">
|
||||
{alreadySaved
|
||||
? "Already in this collection"
|
||||
: `${c.is_public ? "Public" : "Private"} · ${c.item_count} item${
|
||||
c.item_count === 1 ? "" : "s"
|
||||
}${rowMsg[c.id] ? ` · ${rowMsg[c.id]}` : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-white/50 tabular-nums">
|
||||
{col.count}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-white/50">
|
||||
{st === "saving" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : alreadySaved || st === "saved" ? (
|
||||
<Check className="size-4 text-emerald-400" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Create new collection */}
|
||||
{/* Inline "new collection" panel */}
|
||||
{!loading && !error && !showCreate ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement create collection
|
||||
onClose();
|
||||
}}
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
|
||||
>
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
|
||||
<FolderPlus className="size-4 text-amber-400" />
|
||||
<Plus className="size-4 text-amber-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-amber-400">
|
||||
New Collection
|
||||
New collection
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{showCreate ? (
|
||||
<div className="mt-2 rounded-lg border border-white/10 p-3 space-y-3">
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Collection title"
|
||||
maxLength={120}
|
||||
className="w-full bg-white/5 text-white text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-white/60">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newIsPublic}
|
||||
onChange={(e) => setNewIsPublic(e.target.checked)}
|
||||
/>
|
||||
Public
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="px-3 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createCollection}
|
||||
disabled={creating || !newTitle.trim()}
|
||||
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{creating ? "Creating…" : "Create & save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ComposePost — drawer (mobile) / modal (desktop) for "Share to Feed".
|
||||
*
|
||||
* The caption is locked to the exact selected text per spec:
|
||||
* "Each post must have only the selected text as its caption with a link
|
||||
* to the original text URL from where it was copied."
|
||||
*
|
||||
* Users can attach up to 5 images. Each image is uploaded to R2 via the
|
||||
* BFF presign endpoint before submit, so submit only sends already-stored
|
||||
* public URLs (no multipart upload through the BFF).
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { X, ImagePlus, Trash2, Send } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { posts, SocialError } from "@/lib/social";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { getSourceReference } from "@/lib/source-reference";
|
||||
|
||||
const MAX_IMAGES = 5;
|
||||
const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
|
||||
|
||||
export function ComposePost({
|
||||
open,
|
||||
onClose,
|
||||
selectedText,
|
||||
isMobile,
|
||||
onPosted,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedText: string;
|
||||
isMobile: boolean;
|
||||
onPosted?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [images, setImages] = useState<{ url: string; uploading: boolean }[]>(
|
||||
[],
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
if (files.length === 0) return;
|
||||
const slotsLeft = MAX_IMAGES - images.length;
|
||||
if (slotsLeft <= 0) {
|
||||
setError(`A post can hold at most ${MAX_IMAGES} images.`);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const picked = files.slice(0, slotsLeft);
|
||||
// Optimistically show "uploading" tiles; replace with real URLs as each
|
||||
// upload settles. We track each upload by its index so ordering stays stable.
|
||||
const baseIndex = images.length;
|
||||
setImages((prev) => [
|
||||
...prev,
|
||||
...picked.map(() => ({ url: "", uploading: true })),
|
||||
]);
|
||||
await Promise.all(
|
||||
picked.map(async (file, i) => {
|
||||
try {
|
||||
const url = await posts.uploadImage(file);
|
||||
setImages((prev) => {
|
||||
const next = [...prev];
|
||||
next[baseIndex + i] = { url, uploading: false };
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setImages((prev) => prev.filter((_, idx) => idx !== baseIndex + i));
|
||||
setError(
|
||||
err instanceof SocialError ? err.message : "Image upload failed.",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function removeImage(idx: number) {
|
||||
setImages((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const sourceURL = buildHighlightUrl(selectedText);
|
||||
// Prefer the structured reference (e.g. "Bhagavad-gītā As It Is —
|
||||
// Chapter 1, Verse 1") over the raw <title>, which often carries
|
||||
// site branding ("— Prabhupāda Says") and isn't a clean citation.
|
||||
const ref = getSourceReference();
|
||||
const sourceTitle = ref
|
||||
? ref.detail
|
||||
? `${ref.title} — ${ref.detail}`
|
||||
: ref.title
|
||||
: typeof document !== "undefined"
|
||||
? document.title
|
||||
: "";
|
||||
await posts.create({
|
||||
caption: selectedText,
|
||||
source_url: sourceURL,
|
||||
source_title: sourceTitle,
|
||||
image_urls: images
|
||||
.filter((img) => !img.uploading && img.url)
|
||||
.map((img) => img.url),
|
||||
});
|
||||
onPosted?.();
|
||||
onClose();
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
if (err instanceof SocialError && err.status === 401) {
|
||||
setError("Please log in to post.");
|
||||
} else {
|
||||
setError(err instanceof SocialError ? err.message : "Post failed.");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const body = (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-muted/40 p-4">
|
||||
<p className="text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Caption (locked)
|
||||
</p>
|
||||
<p className="mt-2 font-[family-name:var(--font-title)] text-base leading-relaxed text-foreground">
|
||||
“{selectedText}”
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Images ({images.length}/{MAX_IMAGES})
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={images.length >= MAX_IMAGES || submitting}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
<ImagePlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept={PRESS_IMAGE_TYPES}
|
||||
hidden
|
||||
onChange={onFiles}
|
||||
/>
|
||||
</div>
|
||||
{images.length > 0 ? (
|
||||
<div className="mt-3 grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{images.map((img, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
|
||||
>
|
||||
{img.uploading ? (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Uploading…
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="120px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute top-1 right-1 size-6 rounded-full bg-black/70 text-white inline-flex items-center justify-center hover:bg-black/85"
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={
|
||||
submitting || images.some((i) => i.uploading) || !selectedText
|
||||
}
|
||||
className="w-full h-11 inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground text-base font-medium disabled:opacity-60"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
{submitting ? "Posting…" : "Post to feed"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DrawerContent className="bg-card border-border">
|
||||
<DrawerHeader className="text-left border-b border-border pb-4">
|
||||
<DrawerTitle>Share to feed</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="p-4 pb-8">{body}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card shadow-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h2 className="font-[family-name:var(--font-title)] font-semibold text-lg">
|
||||
Share to feed
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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>
|
||||
</div>
|
||||
<div className="p-5">{body}</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
/**
|
||||
* MorePopover — generic secondary-action menu for the selection toolbar.
|
||||
*
|
||||
* The parent (`TextSelectionToolbar`) owns all side effects and builds
|
||||
* the appropriate `items` array based on auth state. Items can be marked
|
||||
* `locked` to render a small Lock badge — used for the public flow
|
||||
* where every More action sits behind the auth wall.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCopyFeedback, type Position } from "./shared";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bookmark, FolderPlus, Link2, AtSign, Check } from "lucide-react";
|
||||
import { Lock } from "lucide-react";
|
||||
import { type Position } from "./shared";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -14,35 +21,37 @@ import {
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
export type MoreItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
/** Show a lock badge — indicates the action is behind the auth wall. */
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
export function MorePopover({
|
||||
open,
|
||||
position,
|
||||
onClose,
|
||||
onBookmark,
|
||||
onSaveToCollection,
|
||||
onMention,
|
||||
items,
|
||||
isMobile,
|
||||
selectedText,
|
||||
}: {
|
||||
open: boolean;
|
||||
position: Position;
|
||||
onClose: () => void;
|
||||
onBookmark: () => void;
|
||||
onSaveToCollection: () => void;
|
||||
onMention: () => void;
|
||||
items: MoreItem[];
|
||||
isMobile: boolean;
|
||||
selectedText: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
// Delay so the click that opened us doesn't immediately fire it.
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
}, 10);
|
||||
@@ -54,41 +63,6 @@ export function MorePopover({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: "bookmark",
|
||||
label: "Add Bookmark",
|
||||
description: "Save your place",
|
||||
icon: <Bookmark className="size-5" />,
|
||||
onClick: onBookmark,
|
||||
},
|
||||
{
|
||||
key: "collection",
|
||||
label: "Save to Collection",
|
||||
description: "Organize into a collection",
|
||||
icon: <FolderPlus className="size-5" />,
|
||||
onClick: onSaveToCollection,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
label: copied ? "Link Copied!" : "Copy Link",
|
||||
description: "Copy a link to this page",
|
||||
icon: copied ? (
|
||||
<Check className="size-5" />
|
||||
) : (
|
||||
<Link2 className="size-5" />
|
||||
),
|
||||
onClick: () => copy(buildHighlightUrl(selectedText)),
|
||||
},
|
||||
{
|
||||
key: "mention",
|
||||
label: "Mention",
|
||||
description: "Tag someone with this quote",
|
||||
icon: <AtSign className="size-5" />,
|
||||
onClick: onMention,
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile: bottom drawer
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -98,27 +72,29 @@ export function MorePopover({
|
||||
<DrawerTitle className="text-white">More Options</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="p-4 space-y-1">
|
||||
{actions.map((action) => (
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={action.key}
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
if (action.key !== "copy-link") {
|
||||
onClose();
|
||||
}
|
||||
item.onClick();
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-white/10 text-white/60">
|
||||
{action.icon}
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
{action.label}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white/90 truncate">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 truncate">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{action.description}</p>
|
||||
</div>
|
||||
{item.locked ? (
|
||||
<Lock className="size-3.5 text-white/40 shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -134,54 +110,49 @@ export function MorePopover({
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: floating popover
|
||||
const popoverW = 220;
|
||||
const popoverH = actions.length * 44 + 8;
|
||||
// Desktop: floating popover anchored to `position`, flipping when it
|
||||
// would overflow the viewport.
|
||||
const popoverW = 260;
|
||||
const popoverH = items.length * 56 + 8;
|
||||
const pad = 8;
|
||||
|
||||
let popLeft = position.x;
|
||||
let popTop = position.y + 8;
|
||||
|
||||
if (popLeft + popoverW > window.innerWidth - pad) {
|
||||
popLeft = window.innerWidth - popoverW - pad;
|
||||
}
|
||||
if (popLeft < pad) {
|
||||
popLeft = pad;
|
||||
}
|
||||
if (popLeft < pad) popLeft = pad;
|
||||
if (popTop + popoverH > window.innerHeight - pad) {
|
||||
popTop = position.y - popoverH - 8;
|
||||
}
|
||||
if (popTop < pad) {
|
||||
popTop = pad;
|
||||
}
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
zIndex: 90,
|
||||
left: popLeft,
|
||||
top: popTop,
|
||||
};
|
||||
if (popTop < pad) popTop = pad;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="w-55 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
|
||||
style={{ position: "fixed", zIndex: 90, left: popLeft, top: popTop }}
|
||||
className="w-64 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
|
||||
>
|
||||
{actions.map((action) => (
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={action.key}
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
if (action.key !== "copy-link") {
|
||||
onClose();
|
||||
}
|
||||
item.onClick();
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-white/90 hover:bg-white/10 transition-colors text-left"
|
||||
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span className="text-white/50">{action.icon}</span>
|
||||
{action.label}
|
||||
<span className="text-white/60 mt-0.5">{item.icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-white/90 truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="block text-xs text-white/50 truncate">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
{item.locked ? (
|
||||
<Lock className="size-3.5 text-white/40 shrink-0 mt-1" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCopyFeedback } from "./shared";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { buildShortHighlightUrl } from "@/lib/text-highlight";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, MessageSquare, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
@@ -17,13 +18,28 @@ export function ShareModal({
|
||||
onClose,
|
||||
selectedText,
|
||||
isMobile,
|
||||
onShareToFeed,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedText: string;
|
||||
isMobile: boolean;
|
||||
/** Called when the user picks "Share to feed" — parent opens the compose modal. */
|
||||
onShareToFeed?: () => void;
|
||||
}) {
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
async function handleCopyShareLink() {
|
||||
if (generating) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const url = await buildShortHighlightUrl(selectedText);
|
||||
await copy(url);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const shareOptions = [
|
||||
{
|
||||
@@ -32,21 +48,26 @@ export function ShareModal({
|
||||
icon: <MessageSquare className="size-4" />,
|
||||
description: "Post this quote to your feed",
|
||||
onClick: () => {
|
||||
// TODO: Implement share to feed
|
||||
onClose();
|
||||
onShareToFeed?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: "Share Link",
|
||||
label: "Copy Link",
|
||||
icon: <ExternalLink className="size-4" />,
|
||||
description: "Copy a shareable link",
|
||||
onClick: () => {
|
||||
copy(buildHighlightUrl(selectedText));
|
||||
},
|
||||
onClick: handleCopyShareLink,
|
||||
},
|
||||
];
|
||||
|
||||
function linkLabel(optKey: string, optLabel: string) {
|
||||
if (optKey !== "link") return optLabel;
|
||||
if (copied) return "Link Copied!";
|
||||
if (generating) return "Generating link…";
|
||||
return optLabel;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -72,7 +93,7 @@ export function ShareModal({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
|
||||
{linkLabel(opt.key, opt.label)}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{opt.description}</p>
|
||||
</div>
|
||||
@@ -130,7 +151,7 @@ export function ShareModal({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
|
||||
{linkLabel(opt.key, opt.label)}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{opt.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";
|
||||
import { createShortLink } from "@/lib/share";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kautilya Icon
|
||||
@@ -61,20 +62,56 @@ export function useMediaQuery(query: string): boolean {
|
||||
// Hook: copy to clipboard with success feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Match a URL inside the copy payload that carries a `?hl=` highlight
|
||||
// fragment. Used by useCopyFeedback to transparently swap such URLs for the
|
||||
// short `/share/<code>` form before writing to the clipboard. The native
|
||||
// ⌘C / Ctrl+C path goes through a different (synchronous) handler in
|
||||
// text-selection-toolbar.tsx and is intentionally not affected.
|
||||
//
|
||||
// The regex stops at whitespace or trailing punctuation that wouldn't appear
|
||||
// in a real URL (`>`, `]`, etc.), so it works against multi-line copy
|
||||
// payloads like the source-attribution block produced by formatCopyText().
|
||||
const HL_URL_RE = /https?:\/\/[^\s<>"'`)\]]+\?hl=[^\s<>"'`)\]]+/g;
|
||||
|
||||
async function shortenHighlightUrlsIn(text: string): Promise<string> {
|
||||
// Cheap pre-check — most text passed to copy() has no `?hl=` URL.
|
||||
if (!text.includes("?hl=")) return text;
|
||||
|
||||
const matches = Array.from(text.matchAll(HL_URL_RE));
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
let result = text;
|
||||
for (const m of matches) {
|
||||
const longUrl = m[0];
|
||||
try {
|
||||
const u = new URL(longUrl);
|
||||
const relativePath = u.pathname + u.search + u.hash;
|
||||
const code = await createShortLink(relativePath);
|
||||
if (!code) continue; // BFF failed — leave the long URL alone.
|
||||
const shortUrl = new URL(`/share/${code}`, u.origin).toString();
|
||||
result = result.replace(longUrl, shortUrl);
|
||||
} catch {
|
||||
// Malformed URL or unexpected error — fall through with the original.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useCopyFeedback() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const copy = useCallback(async (text: string) => {
|
||||
let success = false;
|
||||
const finalText = await shortenHighlightUrlsIn(text);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
await navigator.clipboard.writeText(finalText);
|
||||
success = true;
|
||||
} catch {
|
||||
// Fallback for iOS Safari and older browsers
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.value = finalText;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.top = "0";
|
||||
ta.style.left = "0";
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SiteBottomNav — mobile-only bottom tab bar.
|
||||
*
|
||||
* Four items: Home (Feed) · Knowledgebase · Search · Account.
|
||||
* Mirrors the Instagram pattern — fixed to the viewport bottom, full
|
||||
* width, equal-width tabs, the active tab uses the primary color.
|
||||
*
|
||||
* Hidden on md+ — desktop uses SiteSidebar.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Home, Search, User as UserIcon } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Item = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
matchPrefixes?: string[];
|
||||
};
|
||||
|
||||
export function SiteBottomNav() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
|
||||
const items: Item[] = [
|
||||
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
||||
{
|
||||
href: "/search",
|
||||
label: "Search",
|
||||
icon: <Search className="size-5" />,
|
||||
// Search is the knowledgebase entry point too — keep the tab lit
|
||||
// while the user is reading anywhere in the library.
|
||||
matchPrefixes: [
|
||||
"/search",
|
||||
"/books",
|
||||
"/letters",
|
||||
"/transcriptions",
|
||||
"/essays",
|
||||
"/glossary",
|
||||
"/journals",
|
||||
"/magazines",
|
||||
"/other-documents",
|
||||
"/disciples",
|
||||
],
|
||||
},
|
||||
{
|
||||
href: user ? "/u/me" : "/login",
|
||||
label: user ? "You" : "Sign in",
|
||||
icon: <UserIcon className="size-5" />,
|
||||
matchPrefixes: ["/u/me", "/login", "/signup"],
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(item: Item): boolean {
|
||||
if (item.matchPrefixes) {
|
||||
return item.matchPrefixes.some((p) =>
|
||||
p === "/" ? pathname === "/" : pathname.startsWith(p),
|
||||
);
|
||||
}
|
||||
if (item.href === "/") return pathname === "/";
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
"md:hidden fixed inset-x-0 bottom-0 z-40",
|
||||
"bg-background/95 backdrop-blur border-t border-border",
|
||||
"pb-[env(safe-area-inset-bottom)]",
|
||||
)}
|
||||
>
|
||||
<ul className="flex items-stretch">
|
||||
{items.map((item) => {
|
||||
const active = isActive(item);
|
||||
return (
|
||||
<li key={item.href} className="flex-1">
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"h-14 flex flex-col items-center justify-center gap-0.5",
|
||||
active
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-[0.65rem] font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
+18
-12
@@ -1,25 +1,31 @@
|
||||
/**
|
||||
* SiteFooter — one slim row. Wordmark on the left, dedication on the
|
||||
* right, About link in the middle. That's all.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-card mt-auto">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Team {" "}
|
||||
<span className="font-medium text-foreground">Prabhupāda Says</span>
|
||||
</p>
|
||||
|
||||
<nav className="flex items-center gap-6">
|
||||
<footer className="mt-auto border-t border-border bg-secondary text-secondary-foreground">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Prabhupāda Says — home"
|
||||
className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.12em] text-[0.7rem] text-foreground/80 hover:text-foreground transition-colors"
|
||||
>
|
||||
Prabhupāda Says
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/about"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link href="/" className="hover:text-foreground transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
</nav>
|
||||
<span aria-hidden>·</span>
|
||||
<span>All glories to Śrīla Prabhupāda</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SiteHeader — minimal sticky transparent header with the brand wordmark
|
||||
* centred. Navigation lives in SiteSidebar (desktop) and SiteBottomNav
|
||||
* (mobile), so the header's only job here is to anchor the brand.
|
||||
*
|
||||
* Mobile also gets a Reading-settings affordance pinned to the right —
|
||||
* the sidebar rail is hidden on mobile, so this is the only easy entry
|
||||
* point to the theme + palette + font controls.
|
||||
*
|
||||
* Layout (mobile):
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────┐
|
||||
* │ Prabhupāda ⚙ │
|
||||
* │ SAYS │
|
||||
* └─────────────────────────────────────────────────────┘
|
||||
*
|
||||
* On md+ the gear hides and the sidebar carries it instead.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
|
||||
<div className="relative h-12 sm:h-14 flex items-center justify-center px-4">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Prabhupāda Says — home"
|
||||
className="leading-none text-foreground font-[family-name:var(--font-title)] transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="block text-center text-[0.8rem] sm:text-base font-semibold uppercase tracking-[0.12em]">
|
||||
Prabhupāda
|
||||
</span>
|
||||
<span className="block text-center text-[0.55rem] sm:text-[0.7rem] font-medium uppercase text-muted-foreground tracking-[0.22em] mt-0.5">
|
||||
Says
|
||||
</span>
|
||||
</Link>
|
||||
<div className="md:hidden absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MobileNavToggle — hamburger button that opens the global section drawer
|
||||
* below the md breakpoint.
|
||||
*
|
||||
* Built on the project's wrapped shadcn Drawer (which composes vaul). The
|
||||
* raw `vaul` primitive + `asChild` pattern was unreliable here — the
|
||||
* wrapped components handle ref forwarding and portal mounting correctly
|
||||
* out of the box, and they already encode the project's z-index convention.
|
||||
*
|
||||
* Kept separate from the per-page MobileNav (which renders a book's
|
||||
* sidebar) so reading pages keep their existing drawer untouched.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
type NavItem = { href: string; label: string };
|
||||
|
||||
const SECONDARY: NavItem[] = [
|
||||
{ href: "/disciples", label: "Disciples" },
|
||||
{ href: "/journals", label: "Journals" },
|
||||
{ href: "/magazines", label: "Magazines" },
|
||||
{ href: "/other-documents", label: "Other Documents" },
|
||||
{ href: "/about", label: "About" },
|
||||
];
|
||||
|
||||
export function MobileNavToggle({
|
||||
items,
|
||||
}: {
|
||||
items: ReadonlyArray<NavItem>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
||||
<DrawerTrigger
|
||||
aria-label="Open menu"
|
||||
className={cn(
|
||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||
"text-foreground/80 hover:text-foreground hover:bg-foreground/5 transition-colors",
|
||||
)}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</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">
|
||||
Prabhupāda Says
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
Quickly jump between sections of Prabhupāda Says.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-2">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-base text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="my-3 border-t border-border" />
|
||||
{SECONDARY.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SiteSidebar — desktop-only thin rail that expands on hover.
|
||||
*
|
||||
* Layout (collapsed, w-16):
|
||||
*
|
||||
* ┌──┐
|
||||
* │🏠│ ← top group (Home / Knowledgebase / Collections /
|
||||
* │📚│ Bookmarks / Mentions / Search)
|
||||
* │📁│
|
||||
* │🔖│
|
||||
* │@ │
|
||||
* │🔍│
|
||||
* │ │
|
||||
* │⋮ │ ← bottom group (Reading settings, Profile)
|
||||
* │👤│
|
||||
* └──┘
|
||||
*
|
||||
* Hovering anywhere on the rail expands it to w-56 and reveals labels.
|
||||
* The rail floats above content (fixed position, the main area is
|
||||
* padded by w-16) so the expansion never causes layout shift.
|
||||
*
|
||||
* Hidden below md — mobile uses SiteBottomNav.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
AtSign,
|
||||
Bookmark,
|
||||
FolderClosed,
|
||||
Home,
|
||||
LogIn,
|
||||
Search,
|
||||
Settings2,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
/** Optional: routes to mark active when the path starts with these.
|
||||
* Used for /books variants (lectures, letters, etc.) all flagging the
|
||||
* Knowledgebase entry. */
|
||||
matchPrefixes?: string[];
|
||||
};
|
||||
|
||||
const TOP_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
||||
{
|
||||
href: "/search",
|
||||
label: "Search",
|
||||
icon: <Search className="size-5" />,
|
||||
// Search doubles as the knowledgebase browse surface — light it up
|
||||
// any time the user is deep inside the library so they have a clear
|
||||
// path back.
|
||||
matchPrefixes: [
|
||||
"/search",
|
||||
"/books",
|
||||
"/letters",
|
||||
"/transcriptions",
|
||||
"/essays",
|
||||
"/glossary",
|
||||
"/journals",
|
||||
"/magazines",
|
||||
"/other-documents",
|
||||
"/disciples",
|
||||
],
|
||||
},
|
||||
{
|
||||
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" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function SiteSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
// While the reading-settings popover is open we pin the rail to its
|
||||
// expanded width so the popover (which anchors to the trigger button)
|
||||
// doesn't visually drift when the user's mouse leaves the rail.
|
||||
const [readingOpen, setReadingOpen] = useState(false);
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.matchPrefixes) {
|
||||
return item.matchPrefixes.some((p) =>
|
||||
p === "/" ? pathname === "/" : pathname.startsWith(p),
|
||||
);
|
||||
}
|
||||
if (item.href === "/") return pathname === "/";
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Primary navigation"
|
||||
data-expanded={readingOpen ? "true" : undefined}
|
||||
className={cn(
|
||||
"hidden md:flex group fixed inset-y-0 left-0 z-40",
|
||||
"transition-[width] duration-200 ease-out",
|
||||
readingOpen ? "w-56" : "w-16 hover:w-56",
|
||||
"bg-background/95 backdrop-blur border-r border-border",
|
||||
"flex-col",
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5"
|
||||
aria-label="Primary"
|
||||
>
|
||||
{TOP_ITEMS.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
active={isActive(item)}
|
||||
expandedOverride={readingOpen}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border py-4 flex flex-col gap-0.5">
|
||||
<SidebarReadingSettings
|
||||
expanded={readingOpen}
|
||||
onOpenChange={setReadingOpen}
|
||||
/>
|
||||
<SidebarProfile
|
||||
authed={!!user}
|
||||
handle={user?.handle ?? null}
|
||||
name={user?.name ?? null}
|
||||
active={pathname.startsWith("/u/me")}
|
||||
expandedOverride={readingOpen}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
active,
|
||||
expandedOverride,
|
||||
}: {
|
||||
item: NavItem;
|
||||
active: boolean;
|
||||
/** Force labels visible when the rail is pinned open (e.g. reading
|
||||
* settings popover is up) and the mouse isn't actually hovering. */
|
||||
expandedOverride?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">{item.icon}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarReadingSettings({
|
||||
expanded,
|
||||
onOpenChange,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-2">
|
||||
<ReadingSettings
|
||||
placement="above-left"
|
||||
onOpenChange={onOpenChange}
|
||||
renderTrigger={({ open, toggle, buttonRef }) => (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label="Reading settings"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
"h-11 w-full flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
||||
)}
|
||||
>
|
||||
<Settings2 className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expanded
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
Reading
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarProfile({
|
||||
authed,
|
||||
handle,
|
||||
name,
|
||||
active,
|
||||
expandedOverride,
|
||||
}: {
|
||||
authed: boolean;
|
||||
handle: string | null;
|
||||
name: string | null;
|
||||
active: boolean;
|
||||
expandedOverride?: boolean;
|
||||
}) {
|
||||
if (!authed) {
|
||||
return (
|
||||
<Link
|
||||
href="/login"
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
||||
)}
|
||||
aria-label="Sign in"
|
||||
title="Sign in"
|
||||
>
|
||||
<LogIn className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
Sign in
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href="/u/me"
|
||||
aria-label="Profile"
|
||||
title={handle ? `@${handle}` : "Profile"}
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
{name || (handle ? `@${handle}` : "Profile")}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectionGridCard — square collection card for the 2-per-row grid view.
|
||||
*
|
||||
* • With cover: cover fills the square; bottom-up overlay for legibility.
|
||||
* • Without cover: the same flat palette-aware surface that post cards
|
||||
* fall back to — pure white on light themes, palette-specific dark
|
||||
* contrast on dark themes (see `--quote-card-bg`).
|
||||
*
|
||||
* Visibility badge (Globe/Lock) sits top-right. Title + count read
|
||||
* bottom-centre in white over the cover image's dark overlay; on the
|
||||
* flat fallback they use `text-foreground` so they invert cleanly with
|
||||
* the theme.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Collection } from "@/lib/social";
|
||||
import { quoteCardBgStyle } from "./post-card-shared";
|
||||
|
||||
export function CollectionGridCard({
|
||||
collection: c,
|
||||
href,
|
||||
}: {
|
||||
collection: Collection;
|
||||
href: string;
|
||||
}) {
|
||||
const hasCover = !!c.cover_url;
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group relative block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all select-none"
|
||||
>
|
||||
{hasCover ? (
|
||||
<>
|
||||
<Image
|
||||
src={c.cover_url!}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, 320px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
)}
|
||||
|
||||
{/* Visibility badge — black pill works on both image overlay and
|
||||
flat surface (white or dark). */}
|
||||
<div className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/40 backdrop-blur-sm px-2.5 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
|
||||
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />}
|
||||
{c.is_public ? "Public" : "Private"}
|
||||
</div>
|
||||
|
||||
{/* Title + count — text colour follows the surface beneath. */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col items-center justify-center text-center px-5 sm:px-7",
|
||||
hasCover ? "text-white" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] text-lg sm:text-2xl font-semibold leading-snug line-clamp-4 break-words",
|
||||
hasCover && "drop-shadow-sm",
|
||||
)}
|
||||
>
|
||||
{c.title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-[0.65rem] uppercase tracking-[0.22em] font-medium",
|
||||
hasCover ? "text-white/85" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{c.item_count} post{c.item_count === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectionScrollCard — Instagram-style horizontal carousel for one
|
||||
* collection.
|
||||
*
|
||||
* • Slide 1 is the collection's "cover" — cover image (or themed
|
||||
* gradient if none) overlaid with the title and item count.
|
||||
* • Slides 2..N are PostGridCards for each post in the collection.
|
||||
*
|
||||
* Posts are fetched lazily: an IntersectionObserver triggers
|
||||
* `collections.get(id)` the first time the carousel scrolls into view,
|
||||
* so a feed of many collections doesn't blow N+1 requests on mount.
|
||||
*
|
||||
* The carousel uses native CSS scroll-snap (horizontal, mandatory) so
|
||||
* touch swipes feel right on mobile. A page-dot indicator below tracks
|
||||
* the current slide.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { collections, type Collection, type Post } from "@/lib/social";
|
||||
import { PostGridCard } from "./post-grid-card";
|
||||
import { quoteCardBgStyle } from "./post-card-shared";
|
||||
|
||||
type FetchState = "idle" | "loading" | "loaded" | "error";
|
||||
|
||||
export function CollectionScrollCard({
|
||||
collection: c,
|
||||
href,
|
||||
initialPosts,
|
||||
onEdit,
|
||||
}: {
|
||||
collection: Collection;
|
||||
/** Link target for the "Open" footer + cover slide click-through. */
|
||||
href: string;
|
||||
/** Pre-loaded posts — skip the lazy fetch when the parent already has
|
||||
* them (e.g. the single-collection page renders one card after its
|
||||
* own collections.get call). */
|
||||
initialPosts?: Post[];
|
||||
/** When provided, shows a compact Edit chip in the cover slide's
|
||||
* bottom-right corner. Owner-only — caller decides whether to pass it. */
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const [posts, setPosts] = useState<Post[]>(initialPosts ?? []);
|
||||
const [fetchState, setFetchState] = useState<FetchState>(
|
||||
initialPosts ? "loaded" : "idle",
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
// Reconcile when the parent supplies fresh posts (e.g. after editing).
|
||||
useEffect(() => {
|
||||
if (!initialPosts) return;
|
||||
setPosts(initialPosts);
|
||||
setFetchState("loaded");
|
||||
}, [initialPosts]);
|
||||
|
||||
// Fetch posts the first time the carousel enters the viewport (with a
|
||||
// 200px rootMargin so we kick it off just before it's visible). Skipped
|
||||
// entirely when `initialPosts` was provided.
|
||||
useEffect(() => {
|
||||
if (fetchState !== "idle") return;
|
||||
const el = cardRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
observer.disconnect();
|
||||
setFetchState("loading");
|
||||
collections
|
||||
.get(c.id)
|
||||
.then(({ posts: rows }) => {
|
||||
setPosts(rows ?? []);
|
||||
setFetchState("loaded");
|
||||
})
|
||||
.catch(() => setFetchState("error"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [c.id, fetchState]);
|
||||
|
||||
// Track which slide is currently snapped. We use scrollLeft / slideWidth
|
||||
// rather than another observer because the slides are full-width siblings
|
||||
// — much cheaper to compute.
|
||||
function onScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const slideWidth = el.offsetWidth;
|
||||
if (slideWidth === 0) return;
|
||||
setActiveIndex(Math.round(el.scrollLeft / slideWidth));
|
||||
}
|
||||
|
||||
function scrollByOne(direction: 1 | -1) {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: el.offsetWidth * direction, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const slideCount = 1 + posts.length;
|
||||
const showArrows = slideCount > 1;
|
||||
|
||||
return (
|
||||
<article ref={cardRef} className="space-y-3">
|
||||
<div className="relative max-w-xl mx-auto">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className={cn(
|
||||
"flex overflow-x-auto snap-x snap-mandatory rounded-2xl",
|
||||
// Hide scrollbar — the carousel is gesture-driven; the dot
|
||||
// indicator below tracks position.
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{/* Cover slide */}
|
||||
<CoverSlide collection={c} href={href} onEdit={onEdit} />
|
||||
|
||||
{/* Post slides (or placeholders while loading) */}
|
||||
{fetchState === "loaded" &&
|
||||
posts.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="w-full shrink-0 snap-start p-px"
|
||||
aria-roledescription="slide"
|
||||
>
|
||||
<PostGridCard post={p} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{fetchState === "loading" ? (
|
||||
<div className="w-full shrink-0 snap-start aspect-square rounded-2xl bg-card ring-1 ring-foreground/10 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showArrows ? (
|
||||
<>
|
||||
<CarouselArrow
|
||||
direction="prev"
|
||||
onClick={() => scrollByOne(-1)}
|
||||
disabled={activeIndex === 0}
|
||||
/>
|
||||
<CarouselArrow
|
||||
direction="next"
|
||||
onClick={() => scrollByOne(1)}
|
||||
disabled={activeIndex >= slideCount - 1}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Dot indicator */}
|
||||
{slideCount > 1 ? (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{Array.from({ length: slideCount }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-full transition-all",
|
||||
i === activeIndex
|
||||
? "size-1.5 bg-foreground"
|
||||
: "size-1.5 bg-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Slide pieces ────────────────────────────────────────────────────────
|
||||
|
||||
function CoverSlide({
|
||||
collection: c,
|
||||
href,
|
||||
onEdit,
|
||||
}: {
|
||||
collection: Collection;
|
||||
href: string;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
// The slide wrapper hosts the Link AND any owner-action chip as
|
||||
// siblings. Nesting a button inside the Link is an a11y anti-pattern
|
||||
// (interactive nested in interactive), so we keep them peers and let
|
||||
// DOM z-order put the chip on top of the Link's bottom-right corner.
|
||||
return (
|
||||
<div className="w-full shrink-0 snap-start relative aspect-square">
|
||||
<Link
|
||||
href={href}
|
||||
className="absolute inset-0 rounded-2xl overflow-hidden ring-1 ring-foreground/10 select-none"
|
||||
aria-label={`Open collection ${c.title}`}
|
||||
>
|
||||
{c.cover_url ? (
|
||||
<>
|
||||
<Image
|
||||
src={c.cover_url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/40 to-black/10" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
)}
|
||||
|
||||
<div className="absolute top-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-black/40 backdrop-blur-sm px-3 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
|
||||
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />}
|
||||
Collection
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col p-6 sm:p-8",
|
||||
// Text colour follows the surface underneath: white over the
|
||||
// image's dark overlay, theme foreground over the flat
|
||||
// fallback so it inverts cleanly with the palette.
|
||||
c.cover_url ? "text-white" : "text-foreground",
|
||||
c.cover_url
|
||||
? "justify-end"
|
||||
: "justify-center items-center text-center",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] text-2xl sm:text-3xl font-semibold leading-tight line-clamp-3 break-words",
|
||||
c.cover_url && "drop-shadow-md",
|
||||
)}
|
||||
>
|
||||
{c.title}
|
||||
</p>
|
||||
{c.description ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-sm line-clamp-2",
|
||||
c.cover_url ? "text-white/85 max-w-md" : "text-muted-foreground max-w-md mx-auto",
|
||||
)}
|
||||
>
|
||||
{c.description}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 text-[0.7rem] uppercase tracking-[0.22em] font-semibold",
|
||||
c.cover_url ? "text-white/75" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{c.item_count} post{c.item_count === 1 ? "" : "s"} · Swipe to browse →
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Edit collection"
|
||||
title="Edit collection"
|
||||
className={cn(
|
||||
"absolute bottom-3 right-3 z-10 inline-flex items-center gap-1",
|
||||
"rounded-full bg-black/50 backdrop-blur-sm hover:bg-black/70",
|
||||
"text-white text-[0.7rem] font-medium uppercase tracking-[0.16em]",
|
||||
"px-2.5 py-1 transition-colors",
|
||||
)}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselArrow({
|
||||
direction,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
direction: "prev" | "next";
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={direction === "prev" ? "Previous slide" : "Next slide"}
|
||||
className={cn(
|
||||
"hidden md:inline-flex absolute top-1/2 -translate-y-1/2 size-9 rounded-full",
|
||||
"bg-black/40 text-white items-center justify-center backdrop-blur-sm",
|
||||
"hover:bg-black/60 transition-all",
|
||||
"disabled:opacity-0 disabled:pointer-events-none",
|
||||
direction === "prev" ? "left-3" : "right-3",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectionViews — grid-only renderer for collection lists.
|
||||
*
|
||||
* `hrefFor(c)` chooses where each card links: the owner page sends users
|
||||
* to /u/me/collections/<id>; the public profile sends them to /c/<id>.
|
||||
* The carousel "scroll view" is reached by clicking through to the
|
||||
* collection detail page.
|
||||
*/
|
||||
|
||||
import type { Collection } from "@/lib/social";
|
||||
import { CollectionGridCard } from "./collection-grid-card";
|
||||
|
||||
export function CollectionViews({
|
||||
collections,
|
||||
hrefFor,
|
||||
emptyMessage,
|
||||
}: {
|
||||
collections: Collection[];
|
||||
hrefFor: (c: Collection) => string;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{emptyMessage ?? "No collections yet."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4">
|
||||
{collections.map((c) => (
|
||||
<CollectionGridCard key={c.id} collection={c} href={hrefFor(c)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EditCollectionDrawer — single-stop editor for a collection.
|
||||
*
|
||||
* • Title, description, public/private toggle
|
||||
* • Cover image upload (signed-URL PUT to R2)
|
||||
* • Reorder posts via drag-and-drop, toggle posts in/out of the collection
|
||||
* • Delete button at the bottom (with confirm modal)
|
||||
*
|
||||
* Right-side panel on `md+`, bottom drawer on mobile. The body scrolls;
|
||||
* header and footer stay pinned so action buttons are always reachable.
|
||||
*
|
||||
* Drag-and-drop is powered by framer-motion's Reorder primitives so we
|
||||
* get touch + mouse support without an extra dependency. Each row has a
|
||||
* dedicated grip handle (Reorder.Item with `dragListener={false}` plus
|
||||
* `dragControls`) so the rest of the row stays clickable for toggling.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import {
|
||||
Globe,
|
||||
GripVertical,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Lock,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import {
|
||||
collections,
|
||||
posts as postsApi,
|
||||
SocialError,
|
||||
type Collection,
|
||||
type Post,
|
||||
} from "@/lib/social";
|
||||
|
||||
type SavedShape = {
|
||||
collection: Collection;
|
||||
posts: Post[];
|
||||
};
|
||||
|
||||
export function EditCollectionDrawer({
|
||||
open,
|
||||
onClose,
|
||||
collection,
|
||||
currentPostIds,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
collection: Collection;
|
||||
/** Post ids currently in the collection, in display order. Used to
|
||||
* seed the drag-sortable list and to diff against the user's
|
||||
* selection + new ordering on save. */
|
||||
currentPostIds: string[];
|
||||
onSaved: (next: SavedShape) => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const direction: "bottom" | "right" = isMobile ? "bottom" : "right";
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [title, setTitle] = useState(collection.title);
|
||||
const [description, setDescription] = useState(collection.description ?? "");
|
||||
const [isPublic, setIsPublic] = useState(collection.is_public);
|
||||
const [coverURL, setCoverURL] = useState<string | null>(collection.cover_url);
|
||||
|
||||
// Source of truth for the in-collection set + order: a plain array of
|
||||
// post ids. We push/pull from this when toggling, and reorder it on
|
||||
// drag. On save we diff against `currentPostIds` to find adds/removes
|
||||
// and push the final order to /items/order.
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(currentPostIds);
|
||||
|
||||
const [allPosts, setAllPosts] = useState<Post[] | null>(null);
|
||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingCover, setUploadingCover] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle(collection.title);
|
||||
setDescription(collection.description ?? "");
|
||||
setIsPublic(collection.is_public);
|
||||
setCoverURL(collection.cover_url);
|
||||
setOrderedIds(currentPostIds);
|
||||
setError(null);
|
||||
}, [open, collection, currentPostIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || allPosts !== null) return;
|
||||
setLoadingPosts(true);
|
||||
postsApi
|
||||
.mine()
|
||||
.then((rows) => setAllPosts(rows ?? []))
|
||||
.catch((err) =>
|
||||
setError(err instanceof SocialError ? err.message : "Could not load posts."),
|
||||
)
|
||||
.finally(() => setLoadingPosts(false));
|
||||
}, [open, allPosts]);
|
||||
|
||||
// Partition the user's posts into "in the collection" (ordered by
|
||||
// `orderedIds`) and "available to add" (everything else, stable).
|
||||
const { inCollection, available } = useMemo(() => {
|
||||
if (!allPosts) return { inCollection: [] as Post[], available: [] as Post[] };
|
||||
const byId = new Map(allPosts.map((p) => [p.id, p]));
|
||||
const selected = new Set(orderedIds);
|
||||
const inCollection = orderedIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((p): p is Post => !!p);
|
||||
const available = allPosts.filter((p) => !selected.has(p.id));
|
||||
return { inCollection, available };
|
||||
}, [allPosts, orderedIds]);
|
||||
|
||||
function addToCollection(id: string) {
|
||||
setOrderedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||
}
|
||||
|
||||
function removeFromCollection(id: string) {
|
||||
setOrderedIds((prev) => prev.filter((x) => x !== id));
|
||||
}
|
||||
|
||||
async function onCoverChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
if (!f) return;
|
||||
setUploadingCover(true);
|
||||
try {
|
||||
const url = await collections.uploadCover(f);
|
||||
setCoverURL(url);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof SocialError ? err.message : "Cover upload failed.",
|
||||
);
|
||||
} finally {
|
||||
setUploadingCover(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCover() {
|
||||
setCoverURL(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const before = new Set(currentPostIds);
|
||||
const after = new Set(orderedIds);
|
||||
const toAdd = orderedIds.filter((id) => !before.has(id));
|
||||
const toRemove = currentPostIds.filter((id) => !after.has(id));
|
||||
|
||||
await collections.update(collection.id, {
|
||||
title,
|
||||
description,
|
||||
is_public: isPublic,
|
||||
cover_url: coverURL ?? "",
|
||||
});
|
||||
// Sequential mutations — the count trigger races on parallel calls.
|
||||
for (const id of toAdd) await collections.addItem(collection.id, id);
|
||||
for (const id of toRemove)
|
||||
await collections.removeItem(collection.id, id);
|
||||
// Final position pass — overwrites whatever positions addItem
|
||||
// assigned (MAX+1) so the user's drag order wins.
|
||||
if (orderedIds.length > 0) {
|
||||
await collections.reorderItems(collection.id, orderedIds);
|
||||
}
|
||||
|
||||
const fresh = await collections.get(collection.id);
|
||||
onSaved({ collection: fresh.collection, posts: fresh.posts ?? [] });
|
||||
toast.success("Collection updated");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof SocialError ? err.message : "Save failed.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function performDelete() {
|
||||
try {
|
||||
await collections.delete(collection.id);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err instanceof SocialError ? err.message : "Could not delete.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(o) => !o && !saving && onClose()}
|
||||
direction={direction}
|
||||
shouldScaleBackground={direction === "bottom"}
|
||||
>
|
||||
<DrawerContent
|
||||
direction={direction}
|
||||
className={
|
||||
direction === "bottom"
|
||||
? "max-h-[90vh]"
|
||||
: "max-w-md sm:max-w-lg"
|
||||
}
|
||||
>
|
||||
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2">
|
||||
<DrawerTitle>Edit collection</DrawerTitle>
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
disabled={saving}
|
||||
className="size-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5">
|
||||
{/* Cover */}
|
||||
<section className="flex items-start gap-4">
|
||||
<div className="relative size-24 rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 shrink-0">
|
||||
{coverURL ? (
|
||||
<Image
|
||||
src={coverURL}
|
||||
alt=""
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[0.6rem] uppercase tracking-[0.2em] text-muted-foreground text-center px-2">
|
||||
No cover
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 min-w-0">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
hidden
|
||||
onChange={onCoverChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={uploadingCover}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 h-9 text-sm text-foreground hover:bg-muted border border-border w-fit disabled:opacity-50"
|
||||
>
|
||||
{uploadingCover ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="size-4" />
|
||||
)}
|
||||
{coverURL ? "Change cover" : "Add cover"}
|
||||
</button>
|
||||
{coverURL ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCover}
|
||||
className="text-xs text-muted-foreground hover:text-destructive w-fit"
|
||||
>
|
||||
Remove cover
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Title */}
|
||||
<section className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
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"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Description */}
|
||||
<section className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Optional"
|
||||
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"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Visibility */}
|
||||
<section>
|
||||
<label className="flex items-center gap-2.5 text-sm cursor-pointer text-foreground">
|
||||
<Checkbox
|
||||
checked={isPublic}
|
||||
onCheckedChange={setIsPublic}
|
||||
ariaLabel="Public collection"
|
||||
/>
|
||||
{isPublic ? (
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
Public — anyone with the link can view
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* Posts in this collection (drag-sortable) */}
|
||||
<section className="space-y-2 pt-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
In this collection
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{orderedIds.length}
|
||||
</span>
|
||||
</div>
|
||||
{loadingPosts ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : inCollection.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No posts in this collection yet — pick one from below.
|
||||
</p>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={orderedIds}
|
||||
onReorder={setOrderedIds}
|
||||
className="rounded-lg border border-border divide-y divide-border bg-background"
|
||||
as="ul"
|
||||
>
|
||||
{inCollection.map((p) => (
|
||||
<SortableRow
|
||||
key={p.id}
|
||||
post={p}
|
||||
onRemove={() => removeFromCollection(p.id)}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Available posts */}
|
||||
{available.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Add posts
|
||||
</label>
|
||||
<ul className="rounded-lg border border-border divide-y divide-border bg-background">
|
||||
{available.map((p) => (
|
||||
<AvailableRow
|
||||
key={p.id}
|
||||
post={p}
|
||||
onAdd={() => addToCollection(p.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="shrink-0 border-t border-border p-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 h-10 text-sm text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center rounded-md px-4 h-10 text-sm text-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving || uploadingCover || title.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-4 h-10 bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
title={`Delete "${collection.title}"?`}
|
||||
message="The collection will be removed permanently. The posts inside stay on your feed."
|
||||
confirmLabel="Delete collection"
|
||||
onConfirm={performDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row pieces ──────────────────────────────────────────────────────────
|
||||
|
||||
function SortableRow({
|
||||
post,
|
||||
onRemove,
|
||||
}: {
|
||||
post: Post;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
// dragListener={false} disables the default whole-item drag — only the
|
||||
// grip handle starts a drag via dragControls.start.
|
||||
const controls = useDragControls();
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={post.id}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors bg-background select-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
aria-label="Drag to reorder"
|
||||
title="Drag to reorder"
|
||||
className="mt-0.5 text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing touch-none"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
<Checkbox
|
||||
checked
|
||||
onCheckedChange={() => onRemove()}
|
||||
ariaLabel="Remove from collection"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<PostSummary post={post} />
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function AvailableRow({ post, onAdd }: { post: Post; onAdd: () => void }) {
|
||||
return (
|
||||
<li>
|
||||
<label className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors cursor-pointer">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={() => onAdd()}
|
||||
ariaLabel="Add to collection"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<PostSummary post={post} />
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSummary({ post }: { post: Post }) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-foreground line-clamp-2 break-words">
|
||||
{post.caption}
|
||||
</span>
|
||||
{post.source_title ? (
|
||||
<span className="block text-[0.65rem] uppercase tracking-[0.18em] text-muted-foreground mt-1 truncate">
|
||||
{post.source_title}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PageBookmarks — persistent in-text bookmark highlighting + floating X.
|
||||
*
|
||||
* For every bookmark the logged-in user has whose `source_url` path
|
||||
* matches the current pathname, we:
|
||||
*
|
||||
* 1. Wrap the passage in a `<mark data-bookmark-id="…">` with a violet
|
||||
* wash so the text stays highlighted across the whole session.
|
||||
* 2. Anchor a floating "Bookmarked × remove" pill 32px above the first
|
||||
* mark, scrolling with the page.
|
||||
* 3. Remove both the mark AND the pill when the user clicks ×.
|
||||
*
|
||||
* Cross-component sync: the selection toolbar dispatches
|
||||
* `spsays:bookmarks-changed` whenever it creates a bookmark; we re-fetch
|
||||
* and re-mark on that event, so a freshly-bookmarked passage lights up
|
||||
* immediately without a page reload.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Bookmark, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import {
|
||||
bookmarks as bookmarksApi,
|
||||
type Bookmark as BookmarkRec,
|
||||
} from "@/lib/social";
|
||||
import {
|
||||
decodeTextFragment,
|
||||
markFragmentAsBookmark,
|
||||
removeBookmarkMarks,
|
||||
} from "@/lib/text-highlight";
|
||||
|
||||
type Marker = {
|
||||
bookmark: BookmarkRec;
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
export function PageBookmarks() {
|
||||
const { user, loading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [list, setList] = useState<BookmarkRec[]>([]);
|
||||
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||
// Track which bookmark ids have already been applied to the DOM so we
|
||||
// don't double-wrap when state churns (e.g. an optimistic delete that
|
||||
// then errors and gets restored).
|
||||
const appliedIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const onThisPage = useMemo(
|
||||
() => list.filter((b) => pathOf(b.source_url) === pathname),
|
||||
[list, pathname],
|
||||
);
|
||||
|
||||
// Fetch bookmarks once per user. The toolbar fires a custom event when
|
||||
// it creates a new one so we refresh without polling.
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
setList([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const refetch = () =>
|
||||
bookmarksApi
|
||||
.list()
|
||||
.then((rows) => {
|
||||
if (!cancelled) setList(rows ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
/* overlay is non-essential — swallow errors */
|
||||
});
|
||||
refetch();
|
||||
const onChanged = () => {
|
||||
void refetch();
|
||||
};
|
||||
window.addEventListener("spsays:bookmarks-changed", onChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("spsays:bookmarks-changed", onChanged);
|
||||
};
|
||||
}, [user, loading]);
|
||||
|
||||
// Apply marks + measure icon positions. Runs whenever the relevant
|
||||
// bookmarks for this page change, OR when the viewport resizes so a
|
||||
// reflowed paragraph doesn't strand the floating icon.
|
||||
const applyAndMeasure = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Garbage-collect marks for bookmarks that no longer exist on this
|
||||
// page (deleted, or navigated to a different URL).
|
||||
const wantedIds = new Set(onThisPage.map((b) => b.id));
|
||||
for (const id of appliedIdsRef.current) {
|
||||
if (!wantedIds.has(id)) {
|
||||
removeBookmarkMarks(id);
|
||||
appliedIdsRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const next: Marker[] = [];
|
||||
for (const bookmark of onThisPage) {
|
||||
const hl = hlOf(bookmark.source_url);
|
||||
if (!hl) continue;
|
||||
const fragment = decodeTextFragment(hl);
|
||||
if (!fragment) continue;
|
||||
|
||||
// Wrap text in <mark> if not already wrapped. Idempotent.
|
||||
const marks = markFragmentAsBookmark(fragment, bookmark.id);
|
||||
if (marks.length === 0) continue;
|
||||
appliedIdsRef.current.add(bookmark.id);
|
||||
|
||||
// Anchor the floating icon to the first mark's bounding rect.
|
||||
const rect = marks[0].getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) continue;
|
||||
next.push({
|
||||
bookmark,
|
||||
top: rect.top + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
}
|
||||
setMarkers(next);
|
||||
}, [onThisPage]);
|
||||
|
||||
// Run the apply/measure cycle when this page's bookmarks change. Some
|
||||
// pages stream content in (the verses page), so we retry a few times to
|
||||
// catch text nodes added shortly after mount.
|
||||
useEffect(() => {
|
||||
if (onThisPage.length === 0) {
|
||||
// No bookmarks for this page — strip any leftover marks (e.g. from
|
||||
// a stale render before list rehydrated) and stop.
|
||||
removeBookmarkMarks();
|
||||
appliedIdsRef.current.clear();
|
||||
setMarkers([]);
|
||||
return;
|
||||
}
|
||||
let attempts = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const tick = () => {
|
||||
applyAndMeasure();
|
||||
attempts++;
|
||||
if (attempts < 6) timer = setTimeout(tick, 300);
|
||||
};
|
||||
tick();
|
||||
window.addEventListener("resize", applyAndMeasure);
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
window.removeEventListener("resize", applyAndMeasure);
|
||||
};
|
||||
}, [onThisPage, applyAndMeasure]);
|
||||
|
||||
// Belt-and-braces: when this component unmounts (route change), tear
|
||||
// down every mark so the next page starts clean.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeBookmarkMarks();
|
||||
appliedIdsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function remove(id: string) {
|
||||
const previous = list;
|
||||
setList((rows) => rows.filter((b) => b.id !== id));
|
||||
// Yank the in-text mark immediately for snappy feedback. The
|
||||
// applyAndMeasure effect would also catch this, but we don't want a
|
||||
// visible flash of the still-highlighted text while it runs.
|
||||
removeBookmarkMarks(id);
|
||||
appliedIdsRef.current.delete(id);
|
||||
try {
|
||||
await bookmarksApi.delete(id);
|
||||
toast.success("Bookmark removed");
|
||||
} catch {
|
||||
setList(previous);
|
||||
toast.error("Could not remove bookmark");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
if (markers.length === 0) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{markers.map((m) => (
|
||||
<div
|
||||
key={m.bookmark.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
// top = top-of-text minus pill height (~26) minus tail (6) minus
|
||||
// a 4px breathing gap so the tail tip floats just above the
|
||||
// highlighted line.
|
||||
top: m.top - 36,
|
||||
left: m.left,
|
||||
zIndex: 40,
|
||||
}}
|
||||
className="pointer-events-none"
|
||||
>
|
||||
<div className="pointer-events-auto relative inline-flex items-center gap-1 rounded-full bg-violet-500 shadow-lg px-2.5 py-1 animate-in fade-in-0 zoom-in-95">
|
||||
<Bookmark className="size-3.5 text-white" />
|
||||
<span className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold text-white">
|
||||
Bookmarked
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(m.bookmark.id)}
|
||||
aria-label="Remove bookmark"
|
||||
className="size-5 ml-0.5 inline-flex items-center justify-center rounded-full text-white/80 hover:bg-white/20 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Teardrop pointer to the text below. CSS triangle made with
|
||||
borders: transparent sides + violet top create a down-arrow
|
||||
that visually connects the pill to its highlighted passage. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-3 top-full size-0 border-x-[5px] border-x-transparent border-t-[6px] border-t-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function pathOf(sourceURL: string): string {
|
||||
const q = sourceURL.indexOf("?");
|
||||
return q < 0 ? sourceURL : sourceURL.slice(0, q);
|
||||
}
|
||||
|
||||
function hlOf(sourceURL: string): string | null {
|
||||
const q = sourceURL.indexOf("?");
|
||||
if (q < 0) return null;
|
||||
const params = new URLSearchParams(sourceURL.slice(q + 1));
|
||||
return params.get("hl");
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostActionsFooter — the icon-button row at the bottom of a post card.
|
||||
*
|
||||
* Left cluster: Like · Comment · Reshare
|
||||
* Right cluster: Save to Collection (folder-plus)
|
||||
*
|
||||
* Like / Comment / Reshare are placeholders for now — they fire a
|
||||
* "coming soon" toast on click. Save to Collection mounts the existing
|
||||
* CollectionsDrawer in `existingPostId` mode so the post is attached
|
||||
* directly without creating a duplicate.
|
||||
*
|
||||
* Auth gating is handled per-action via `useAuthGate` — anonymous users
|
||||
* see the in-place auth modal before the underlying action runs.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { FolderPlus, Heart, MessageCircle, Repeat2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
|
||||
|
||||
export function PostActionsFooter({ postID }: { postID: string }) {
|
||||
const { requireAuth } = useAuthGate();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [collectionsOpen, setCollectionsOpen] = useState(false);
|
||||
|
||||
async function withAuth(fn: () => void | Promise<void>) {
|
||||
if (!(await requireAuth())) return;
|
||||
await fn();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ActionButton
|
||||
label="Like"
|
||||
onClick={() =>
|
||||
withAuth(() => {
|
||||
toast("Likes are coming soon", { duration: 2000 });
|
||||
})
|
||||
}
|
||||
icon={<Heart className="size-5" />}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Comment"
|
||||
onClick={() =>
|
||||
withAuth(() => {
|
||||
toast("Comments are coming soon", { duration: 2000 });
|
||||
})
|
||||
}
|
||||
icon={<MessageCircle className="size-5" />}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Reshare"
|
||||
onClick={() =>
|
||||
withAuth(() => {
|
||||
toast("Resharing is coming soon", { duration: 2000 });
|
||||
})
|
||||
}
|
||||
icon={<Repeat2 className="size-5" />}
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
label="Save to collection"
|
||||
onClick={() => withAuth(() => setCollectionsOpen(true))}
|
||||
icon={<FolderPlus className="size-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CollectionsDrawer
|
||||
open={collectionsOpen}
|
||||
onClose={() => setCollectionsOpen(false)}
|
||||
isMobile={isMobile}
|
||||
existingPostId={postID}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"size-10 inline-flex items-center justify-center rounded-full",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Helpers shared by PostGridCard and PostScrollCard.
|
||||
*
|
||||
* • postSourceHref — synthesizes `?hl=` if the stored URL lacks one
|
||||
* • themeGradientStyle — picks one of four palette-aware gradients
|
||||
* deterministically from a seed (post id) so a
|
||||
* feed of image-less posts looks varied without
|
||||
* shifting between renders.
|
||||
*/
|
||||
|
||||
import type { Post } from "@/lib/social";
|
||||
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
|
||||
|
||||
export function postSourceHref(post: Post): string {
|
||||
return hasHighlight(post.source_url) || !post.caption
|
||||
? post.source_url
|
||||
: appendHighlight(post.source_url, post.caption);
|
||||
}
|
||||
|
||||
// Quote-card gradients.
|
||||
//
|
||||
// Four CSS custom properties (`--quote-gradient-1` … 4) are defined per
|
||||
// palette in `app/globals.css`. Each palette ships its own hand-tuned
|
||||
// gradient set so the cards swap colour with the active theme:
|
||||
//
|
||||
// • Aged Parchment → mahogany binding + temple gold
|
||||
// • Tulasī Garden → forest depths + amber edge
|
||||
// • Yamunā Mist → river depths + dusk ochre
|
||||
// • Ancient Manuscript → midnight leather + saffron
|
||||
// • Nīlamaṇi → Kṛṣṇa's sapphire hour (deep blues)
|
||||
// • Vraja Twilight → deep wine + rose dusk
|
||||
//
|
||||
// All variants are dark-leaning so `text-white` reads cleanly. The card
|
||||
// picks one of the four deterministically from `post.id`.
|
||||
const GRADIENT_VARS = [
|
||||
"var(--quote-gradient-1)",
|
||||
"var(--quote-gradient-2)",
|
||||
"var(--quote-gradient-3)",
|
||||
"var(--quote-gradient-4)",
|
||||
];
|
||||
|
||||
/** Returns an inline-style object that applies the chosen gradient. The
|
||||
* style approach (vs a className) lets us reference CSS custom properties
|
||||
* that vary per palette — Tailwind can't generate utility classes for
|
||||
* values that change at runtime via `:root` selectors. */
|
||||
export function themeGradientStyle(seed: string): React.CSSProperties {
|
||||
// Cheap, stable hash — same seed always picks the same gradient.
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
return { backgroundImage: GRADIENT_VARS[h % GRADIENT_VARS.length] };
|
||||
}
|
||||
|
||||
/** Flat background for post cards without an image: pure white on every
|
||||
* light palette, palette-specific darker contrast on dark palettes.
|
||||
* Defined as `--quote-card-bg` per `:root` / `.dark` block in
|
||||
* globals.css. Combine with `text-foreground` for legibility — both
|
||||
* sides of the theme switch reverse together. */
|
||||
export function quoteCardBgStyle(): React.CSSProperties {
|
||||
return { backgroundColor: "var(--quote-card-bg)" };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Variable quote sizing
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A pithy two-word quote and a full paragraph need very different
|
||||
// treatments inside a fixed-size card. We pick a tier from the caption's
|
||||
// character count:
|
||||
//
|
||||
// tier 1 (very short) — biggest font, dead-centre, no truncation needed
|
||||
// tier 2 (short) — large font, centred, ~5-line clamp
|
||||
// tier 3 (medium) — medium font, centred, ~6/7-line clamp
|
||||
// tier 4 (long) — smallest font, anchored bottom-LEFT and left-
|
||||
// aligned (a centred wall of small text would
|
||||
// cover the entire card and look claustrophobic;
|
||||
// bottom-left mimics the image variant's caption
|
||||
// placement and breathes better).
|
||||
//
|
||||
// Two scales: "grid" for the small square card, "scroll" for the larger
|
||||
// Instagram-style card.
|
||||
|
||||
export type QuoteSize = {
|
||||
/** Tailwind font-size classes, e.g. "text-xl sm:text-2xl" */
|
||||
fontClass: string;
|
||||
/** Tailwind line-clamp class limiting overflow */
|
||||
clampClass: string;
|
||||
/** Where to anchor the text block inside its container */
|
||||
alignment: "center" | "bottom-left";
|
||||
};
|
||||
|
||||
export function quoteSize(text: string, variant: "grid" | "scroll"): QuoteSize {
|
||||
const len = (text ?? "").trim().length;
|
||||
if (variant === "grid") {
|
||||
if (len <= 50)
|
||||
return {
|
||||
fontClass: "text-xl sm:text-2xl",
|
||||
clampClass: "line-clamp-3",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 150)
|
||||
return {
|
||||
fontClass: "text-base sm:text-lg",
|
||||
clampClass: "line-clamp-5",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 300)
|
||||
return {
|
||||
fontClass: "text-sm sm:text-base",
|
||||
clampClass: "line-clamp-[7]",
|
||||
alignment: "center",
|
||||
};
|
||||
return {
|
||||
fontClass: "text-xs sm:text-sm",
|
||||
clampClass: "line-clamp-[9]",
|
||||
alignment: "bottom-left",
|
||||
};
|
||||
}
|
||||
// scroll — bigger card, larger fonts at every tier, higher thresholds
|
||||
// so paragraph-length quotes still get centred at a comfortable size.
|
||||
if (len <= 60)
|
||||
return {
|
||||
fontClass: "text-3xl sm:text-4xl",
|
||||
clampClass: "line-clamp-3",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 180)
|
||||
return {
|
||||
fontClass: "text-2xl sm:text-3xl",
|
||||
clampClass: "line-clamp-5",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 380)
|
||||
return {
|
||||
fontClass: "text-lg sm:text-xl",
|
||||
clampClass: "line-clamp-[8]",
|
||||
alignment: "center",
|
||||
};
|
||||
return {
|
||||
fontClass: "text-base sm:text-lg",
|
||||
clampClass: "line-clamp-[10]",
|
||||
alignment: "bottom-left",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostCard — renders one post in the public feed and the /u/me feed.
|
||||
*
|
||||
* • Caption in serif (locked, can't be edited)
|
||||
* • Image grid (max 5; collapses to one column on the cheap)
|
||||
* • "Source" link back to the page the quote came from
|
||||
* • Optional onDelete callback shown only when the owner is viewing
|
||||
*
|
||||
* The source link always lands the user on the original passage with `?hl=`
|
||||
* highlighting. If the stored source_url already carries `?hl=` we use it
|
||||
* verbatim; otherwise we synthesise one from the post's caption so older
|
||||
* posts (or imported data) still scroll to + highlight the text on click.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Trash2, ExternalLink } from "lucide-react";
|
||||
import type { Post } from "@/lib/social";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
|
||||
import { PostActionsFooter } from "./post-actions-footer";
|
||||
|
||||
export function PostCard({
|
||||
post,
|
||||
onDelete,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const sourceHref =
|
||||
hasHighlight(post.source_url) || !post.caption
|
||||
? post.source_url
|
||||
: appendHighlight(post.source_url, post.caption);
|
||||
|
||||
return (
|
||||
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
|
||||
<p className="font-[family-name:var(--font-title)] text-lg text-foreground leading-relaxed">
|
||||
“{post.caption}”
|
||||
</p>
|
||||
|
||||
{post.images && post.images.length > 0 ? (
|
||||
<div
|
||||
className={`mt-4 grid gap-2 ${
|
||||
post.images.length === 1
|
||||
? "grid-cols-1"
|
||||
: post.images.length === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{post.images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 30vw, 200px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<Link
|
||||
href={sourceHref}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{displaySourceLabel(post.source_url, post.source_title)}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{new Date(post.created_at).toLocaleDateString()}</span>
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(post.id)}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Delete post"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
collection on the right. */}
|
||||
<div className="mt-3 pt-2 border-t border-border">
|
||||
<PostActionsFooter postID={post.id} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostGridCard — square card used in the 2-per-row grid view.
|
||||
*
|
||||
* • With image: first attached image fills the card; a bottom-up dark
|
||||
* gradient makes the caption legible without dropping the image's
|
||||
* atmosphere.
|
||||
* • Without image: a palette-aware Tailwind gradient stands in; the
|
||||
* caption sits as a serif quote bottom-left in the foreground colour.
|
||||
*
|
||||
* `onDelete` shows a small X in the corner for owner views. Used both for
|
||||
* actual post deletion (on /u/me) and for removing from a collection
|
||||
* (on /u/me/collections/[id]); the parent decides which.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ImageIcon, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Post } from "@/lib/social";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { postSourceHref, quoteCardBgStyle, quoteSize } from "./post-card-shared";
|
||||
import { UpdateBackgroundModal } from "./update-background-modal";
|
||||
|
||||
export function PostGridCard({
|
||||
post,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
href: hrefProp,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
/** Owner-only: shown when defined so the user can swap/remove the
|
||||
* card's background image. */
|
||||
onUpdated?: (updated: Post) => void;
|
||||
/** Override the click target. Defaults to the source-passage URL.
|
||||
* Profile grids pass a `/u/<handle>/posts#post-<id>` anchor so the
|
||||
* thumbnail jumps to the Instagram-style scroll feed instead of the
|
||||
* knowledgebase page. */
|
||||
href?: string;
|
||||
}) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [bgModal, setBgModal] = useState(false);
|
||||
const firstImage = post.images?.[0]?.url;
|
||||
const href = hrefProp ?? postSourceHref(post);
|
||||
const size = quoteSize(post.caption, "grid");
|
||||
// Image variant always anchors text bottom-left over the image's dark
|
||||
// overlay. No-image variant lets `size.alignment` decide — short quotes
|
||||
// sit centred so the gradient frames them; long quotes fall back to
|
||||
// bottom-left so they don't cover the whole card.
|
||||
const bottomLeft = !!firstImage || size.alignment === "bottom-left";
|
||||
|
||||
return (
|
||||
<div className="relative group select-none">
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10",
|
||||
"hover:ring-foreground/25 hover:shadow-md transition-all relative",
|
||||
)}
|
||||
>
|
||||
{firstImage ? (
|
||||
<>
|
||||
<Image
|
||||
src={firstImage}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, 320px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
{/* Bottom-up gradient for caption legibility — keeps the image
|
||||
visible up top while text sits over solid darkness below. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col p-4 sm:p-5",
|
||||
// Text color: white on the dark image overlay, theme
|
||||
// foreground on the flat quote-card background.
|
||||
firstImage ? "text-white" : "text-foreground",
|
||||
bottomLeft
|
||||
? "justify-end items-start text-left"
|
||||
: "justify-center items-center text-center",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] leading-snug break-words",
|
||||
size.fontClass,
|
||||
size.clampClass,
|
||||
// Drop-shadow only helps when text sits on imagery; on the
|
||||
// flat bg it just blurs the type.
|
||||
firstImage && "drop-shadow-sm",
|
||||
// Centred quotes need a soft horizontal cap so a long line
|
||||
// doesn't kiss the card edge — looks better with margins.
|
||||
!bottomLeft ? "max-w-[90%]" : "w-full",
|
||||
)}
|
||||
>
|
||||
“{post.caption}”
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-[0.65rem] uppercase tracking-[0.2em] font-medium truncate",
|
||||
firstImage ? "text-white/80" : "text-muted-foreground",
|
||||
bottomLeft ? "w-full" : "max-w-[90%]",
|
||||
)}
|
||||
>
|
||||
{displaySourceLabel(post.source_url, post.source_title)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{onUpdated || onDelete ? (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
{onUpdated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBgModal(true)}
|
||||
aria-label="Update background"
|
||||
title="Update background"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(post.id)}
|
||||
aria-label="Remove"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onUpdated ? (
|
||||
<UpdateBackgroundModal
|
||||
open={bgModal}
|
||||
onClose={() => setBgModal(false)}
|
||||
post={post}
|
||||
isMobile={isMobile}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostScrollCard — Instagram-style post used on the scroll feed.
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* ┌───────────────────────────────────────┐
|
||||
* │ ◯ name @handle ⋮ │ ← author header (TBD)
|
||||
* ├───────────────────────────────────────┤
|
||||
* │ │
|
||||
* │ image carousel │ ← square, swipeable
|
||||
* │ (or quote) │ (no-image variant
|
||||
* │ │ fills with gradient
|
||||
* │ • ○ ○ ○ │ panel + serif quote)
|
||||
* ├───────────────────────────────────────┤
|
||||
* │ "Quoted passage shown as caption…" │ ← truncates with "more"
|
||||
* │ ŚRĪMAD-BHĀGAVATAM — CANTO 4, CH 5 │ ← citation
|
||||
* └───────────────────────────────────────┘
|
||||
*
|
||||
* • Multi-image carousel uses native CSS scroll-snap with dot indicators
|
||||
* below; the same primitive CollectionScrollCard uses for posts.
|
||||
* • Long captions clamp to 3 lines until the user expands via "more".
|
||||
* • Owner action cluster (Update background / Delete) sits in the
|
||||
* top-right of the media area, fading in on hover (desktop) and
|
||||
* always visible on touch (mobile).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ImageIcon, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Post, PostImage } from "@/lib/social";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
|
||||
import { PostActionsFooter } from "./post-actions-footer";
|
||||
import { UpdateBackgroundModal } from "./update-background-modal";
|
||||
|
||||
export function PostScrollCard({
|
||||
post,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
onUpdated?: (updated: Post) => void;
|
||||
}) {
|
||||
const images = post.images ?? [];
|
||||
const hasImages = images.length > 0;
|
||||
const sourceHref = postSourceHref(post);
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [bgModal, setBgModal] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const ownerCluster =
|
||||
onUpdated || onDelete ? (
|
||||
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5">
|
||||
{onUpdated ? (
|
||||
<OwnerButton
|
||||
label="Update background"
|
||||
onClick={() => setBgModal(true)}
|
||||
icon={<ImageIcon className="size-4" />}
|
||||
/>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<OwnerButton
|
||||
label="Remove"
|
||||
onClick={() => onDelete(post.id)}
|
||||
icon={<Trash2 className="size-4" />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`post-${post.id}`}
|
||||
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
|
||||
>
|
||||
{/* Author header */}
|
||||
<header className="flex items-center gap-3 px-4 py-3">
|
||||
<AuthorAvatar
|
||||
src={post.author_avatar_url ?? null}
|
||||
name={post.author_name ?? "Unknown"}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={
|
||||
post.author_handle ? `/u/${post.author_handle}` : "#"
|
||||
}
|
||||
className="block text-sm font-semibold text-foreground truncate hover:underline"
|
||||
>
|
||||
{post.author_name || "Unknown"}
|
||||
</Link>
|
||||
{post.author_handle ? (
|
||||
<span className="block text-xs text-muted-foreground truncate">
|
||||
@{post.author_handle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Media — image carousel or gradient-with-quote */}
|
||||
<div className="relative">
|
||||
{hasImages ? (
|
||||
<ImageCarousel images={images} />
|
||||
) : (
|
||||
<QuotePanel post={post} />
|
||||
)}
|
||||
{ownerCluster}
|
||||
</div>
|
||||
|
||||
{/* Caption + citation — reference sits directly under the media
|
||||
so it's read first, with the action row below it. */}
|
||||
<div className="px-4 pt-4 pb-3 space-y-2">
|
||||
{hasImages ? (
|
||||
// When images carry the visual, the caption sits as text below.
|
||||
<Caption
|
||||
text={post.caption}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((v) => !v)}
|
||||
/>
|
||||
) : null}
|
||||
<Link
|
||||
href={sourceHref}
|
||||
className="block max-w-full truncate text-[0.7rem] uppercase tracking-[0.2em] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{displaySourceLabel(post.source_url, post.source_title)} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
collection on the right. */}
|
||||
<div className="pt-1 pb-2 border-t border-border">
|
||||
<PostActionsFooter postID={post.id} />
|
||||
</div>
|
||||
|
||||
{onUpdated ? (
|
||||
<UpdateBackgroundModal
|
||||
open={bgModal}
|
||||
onClose={() => setBgModal(false)}
|
||||
post={post}
|
||||
isMobile={isMobile}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pieces ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ImageCarousel({ images }: { images: PostImage[] }) {
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||
const el = e.currentTarget;
|
||||
const w = el.offsetWidth;
|
||||
if (w === 0) return;
|
||||
setActive(Math.round(el.scrollLeft / w));
|
||||
}
|
||||
|
||||
if (images.length === 1) {
|
||||
return (
|
||||
<div className="relative aspect-square bg-muted">
|
||||
<Image
|
||||
src={images[0].url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={onScroll}
|
||||
className={cn(
|
||||
"flex overflow-x-auto snap-x snap-mandatory",
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative w-full shrink-0 snap-start aspect-square bg-muted"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${i + 1} of ${images.length}`}
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
|
||||
{images.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-1.5 rounded-full transition-all",
|
||||
i === active ? "bg-white" : "bg-white/50",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotePanel({ post }: { post: Post }) {
|
||||
// No-image post: the entire square is a flat palette-aware surface
|
||||
// (pure white on light themes, palette-specific dark on dark themes)
|
||||
// with the quote in serif inside. Text uses --foreground so it inverts
|
||||
// automatically with the theme.
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-square overflow-hidden select-none"
|
||||
style={quoteCardBgStyle()}
|
||||
>
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center p-8 sm:p-10 text-center text-foreground">
|
||||
<p className="font-[family-name:var(--font-title)] text-xl sm:text-2xl leading-relaxed max-w-md line-clamp-[8] break-words">
|
||||
“{post.caption}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Caption({
|
||||
text,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
text: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
// Show 3 lines collapsed. The "more" toggle appears only when the text
|
||||
// is long enough to warrant it (a cheap char-length check beats a
|
||||
// measure-and-compare pass for our purposes — captions over ~180 chars
|
||||
// will always overflow 3 lines at the rendered font size).
|
||||
const longEnough = text.length > 180;
|
||||
return (
|
||||
<div className="text-sm text-foreground leading-relaxed">
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] break-words",
|
||||
!expanded && longEnough && "line-clamp-3",
|
||||
)}
|
||||
>
|
||||
“{text}”
|
||||
</p>
|
||||
{longEnough ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="mt-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? "Show less" : "More"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorAvatar({
|
||||
src,
|
||||
name,
|
||||
}: {
|
||||
src: string | null;
|
||||
name: string;
|
||||
}) {
|
||||
if (src) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="size-9 rounded-full object-cover ring-1 ring-foreground/10"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
const initial = name.charAt(0).toUpperCase() || "?";
|
||||
return (
|
||||
<div className="size-9 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10">
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OwnerButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"size-9 rounded-full inline-flex items-center justify-center",
|
||||
"bg-black/40 text-white hover:bg-black/60 transition-colors backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostViews — grid-only renderer for post lists.
|
||||
*
|
||||
* Renders a 3-up square grid (Instagram pattern). The previous
|
||||
* Grid/Scroll tab toggle is gone — the scroll feed lives at
|
||||
* /u/<handle>/posts and is reached by clicking a grid thumbnail
|
||||
* (which sets `?hl=` and a hash anchor).
|
||||
*
|
||||
* Callers pass `hrefFor(post)` if they want each thumbnail to link to a
|
||||
* specific URL (e.g. the scroll feed). When omitted, each card defaults
|
||||
* to its source-passage URL.
|
||||
*/
|
||||
|
||||
import type { Post } from "@/lib/social";
|
||||
import { PostGridCard } from "./post-grid-card";
|
||||
|
||||
export function PostViews({
|
||||
posts,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
emptyMessage,
|
||||
hrefFor,
|
||||
}: {
|
||||
posts: Post[];
|
||||
onDelete?: (id: string) => void;
|
||||
onUpdated?: (updated: Post) => void;
|
||||
emptyMessage?: string;
|
||||
hrefFor?: (post: Post) => string;
|
||||
}) {
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{emptyMessage ?? "Nothing here yet."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
{posts.map((p) => (
|
||||
<PostGridCard
|
||||
key={p.id}
|
||||
post={p}
|
||||
onDelete={onDelete}
|
||||
onUpdated={onUpdated}
|
||||
href={hrefFor?.(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UpdateBackgroundModal — swap or remove the image behind a post card.
|
||||
*
|
||||
* Two actions:
|
||||
* • Upload Image — pick a file → signed-URL upload → PUT to BFF
|
||||
* → returns the refreshed post.
|
||||
* • Remove Background — PUT empty image set → post falls back to the
|
||||
* palette-aware quote-card gradient.
|
||||
*
|
||||
* Renders as a centred modal on desktop and a bottom drawer on mobile,
|
||||
* mirroring the pattern used by AuthModal / ConfirmModal so the UX stays
|
||||
* consistent across the app.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ImageIcon, Loader2, Trash2, Upload, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { posts, SocialError, type Post } from "@/lib/social";
|
||||
|
||||
type Busy = "upload" | "remove" | null;
|
||||
|
||||
export function UpdateBackgroundModal({
|
||||
open,
|
||||
onClose,
|
||||
post,
|
||||
isMobile,
|
||||
onUpdated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
post: Post;
|
||||
isMobile: boolean;
|
||||
/** Fires with the refreshed post after a successful upload or removal. */
|
||||
onUpdated?: (updated: Post) => void;
|
||||
}) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState<Busy>(null);
|
||||
const hasImage = (post.images?.length ?? 0) > 0;
|
||||
|
||||
// Body-scroll lock + Esc-to-close — desktop modal only. The drawer
|
||||
// handles both internally.
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && busy === null) onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, isMobile, busy, onClose]);
|
||||
|
||||
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
if (!file) return;
|
||||
setBusy("upload");
|
||||
try {
|
||||
const url = await posts.uploadImage(file);
|
||||
const updated = await posts.replaceImages(post.id, [url]);
|
||||
toast.success("Background updated");
|
||||
onUpdated?.(updated);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof SocialError ? err.message : "Could not update background.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
setBusy("remove");
|
||||
try {
|
||||
const updated = await posts.replaceImages(post.id, []);
|
||||
toast.success("Background removed");
|
||||
onUpdated?.(updated);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof SocialError ? err.message : "Could not remove background.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const body = (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={onUpload}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={busy !== null}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-accent transition-colors text-left disabled:opacity-60"
|
||||
>
|
||||
<span className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary shrink-0">
|
||||
{busy === "upload" ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{hasImage ? "Replace Image" : "Upload Image"}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
JPEG or PNG, recommended ≥ 1080×1080
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{hasImage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
disabled={busy !== null}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-destructive/5 transition-colors text-left disabled:opacity-60"
|
||||
>
|
||||
<span className="flex items-center justify-center size-10 rounded-lg bg-destructive/10 text-destructive shrink-0">
|
||||
{busy === "remove" ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
Remove Background
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Falls back to a theme-coloured gradient
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(o) => !o && busy === null && onClose()}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>Update Background</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4">{body}</div>
|
||||
<div className="p-4 pt-0">
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
className="w-full py-2.5 rounded-lg border border-border text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: centred modal with portal + backdrop, same pattern as
|
||||
// ConfirmModal/AuthModal so behaviour stays consistent.
|
||||
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={() => busy === null && onClose()}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-bg-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">
|
||||
<div className="flex items-start gap-3 px-5 pt-5 pb-3">
|
||||
<span
|
||||
className="shrink-0 size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
<ImageIcon className="size-5" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2
|
||||
id="update-bg-title"
|
||||
className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground"
|
||||
>
|
||||
Update Background
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
Pick an image to set as the card's background, or remove the
|
||||
current one.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => busy === null && onClose()}
|
||||
aria-label="Close"
|
||||
disabled={busy !== null}
|
||||
className="shrink-0 size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Share2, Copy, MoreHorizontal, Check } from "lucide-react";
|
||||
import { formatCopyText } from "@/lib/source-reference";
|
||||
import {
|
||||
AtSign,
|
||||
Bookmark as BookmarkIcon,
|
||||
Copy,
|
||||
FolderPlus,
|
||||
Link as LinkIcon,
|
||||
MoreHorizontal,
|
||||
Send,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
|
||||
import {
|
||||
buildHighlightUrl,
|
||||
buildShortHighlightUrl,
|
||||
} from "@/lib/text-highlight";
|
||||
import { bookmarks, SocialError } from "@/lib/social";
|
||||
import {
|
||||
KautilyaIcon,
|
||||
useMediaQuery,
|
||||
@@ -11,9 +26,11 @@ import {
|
||||
type ToolbarAction,
|
||||
} from "./selection-toolbar/shared";
|
||||
import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
|
||||
import { ShareModal } from "./selection-toolbar/share-modal";
|
||||
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
|
||||
import { MorePopover } from "./selection-toolbar/more-popover";
|
||||
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
|
||||
import { ComposePost } from "./selection-toolbar/compose-post";
|
||||
import { useAuth } from "./auth/auth-provider";
|
||||
import { useAuthGate } from "./auth/auth-gate";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Component
|
||||
@@ -24,10 +41,45 @@ export function TextSelectionToolbar() {
|
||||
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
|
||||
const [showShare, setShowShare] = useState(false);
|
||||
const [showCollections, setShowCollections] = useState(false);
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const { copy } = useCopyFeedback();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const { requireAuth } = useAuthGate();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Cached bookmark count → drives the "Bookmark" / "Collect" swap.
|
||||
// We fetch on auth state change and react to the toolbar's own
|
||||
// `spsays:bookmarks-changed` event so the label flips immediately
|
||||
// after saving the user's last allowed bookmark.
|
||||
const [bookmarkCount, setBookmarkCount] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setBookmarkCount(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const refetch = () => {
|
||||
bookmarks
|
||||
.list()
|
||||
.then((rows) => {
|
||||
if (!cancelled) setBookmarkCount(rows?.length ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
/* non-essential — swallow */
|
||||
});
|
||||
};
|
||||
refetch();
|
||||
const onChanged = () => refetch();
|
||||
window.addEventListener("spsays:bookmarks-changed", onChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("spsays:bookmarks-changed", onChanged);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const bookmarkLimit = user?.plan === "premium" ? 5 : 1;
|
||||
const limitReached = !!user && bookmarkCount >= bookmarkLimit;
|
||||
|
||||
// Track whether the user is interacting with our toolbar/popovers
|
||||
const interactingRef = useRef(false);
|
||||
@@ -66,7 +118,12 @@ export function TextSelectionToolbar() {
|
||||
useEffect(() => {
|
||||
function handleSelectionChange() {
|
||||
// Don't dismiss while interacting with toolbar menus
|
||||
if (interactingRef.current || showMore || showShare || showCollections) {
|
||||
if (
|
||||
interactingRef.current ||
|
||||
showMore ||
|
||||
showCollections ||
|
||||
showCompose
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,7 +243,7 @@ export function TextSelectionToolbar() {
|
||||
document.removeEventListener("touchstart", onTouchStart);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
};
|
||||
}, [clearSelection, showMore, showShare, showCollections]);
|
||||
}, [clearSelection, showMore, showCollections, showCompose]);
|
||||
|
||||
// Intercept native Ctrl+C / Cmd+C to append source reference
|
||||
useEffect(() => {
|
||||
@@ -230,7 +287,7 @@ export function TextSelectionToolbar() {
|
||||
|
||||
const target = e.target as Element;
|
||||
if (target.closest("[data-selection-toolbar]")) return;
|
||||
if (showMore || showShare || showCollections) return;
|
||||
if (showMore || showCollections || showCompose) return;
|
||||
|
||||
const sel = window.getSelection();
|
||||
const text = sel?.toString().trim() ?? "";
|
||||
@@ -241,7 +298,7 @@ export function TextSelectionToolbar() {
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [clearSelection, showMore, showShare, showCollections]);
|
||||
}, [clearSelection, showMore, showCollections, showCompose]);
|
||||
|
||||
// Don't render on RTF viewer or if nothing is selected
|
||||
if (
|
||||
@@ -253,10 +310,92 @@ export function TextSelectionToolbar() {
|
||||
|
||||
if (!toolbarPos || !selectedText) return null;
|
||||
|
||||
// --- Toolbar actions ---
|
||||
// ─── Action handlers ──────────────────────────────────────────────────
|
||||
|
||||
const toolbarActions: ToolbarAction[] = [
|
||||
{
|
||||
function openCollectionsDrawer() {
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setShowCollections(true);
|
||||
}
|
||||
|
||||
async function onPost() {
|
||||
if (!(await requireAuth())) return;
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setShowCompose(true);
|
||||
}
|
||||
|
||||
async function onBookmarkOrCollect() {
|
||||
// Fast path: already at cap → reuse this click to open Save to
|
||||
// Collection. The label is "Collect" in this state so the user knows.
|
||||
if (limitReached) {
|
||||
openCollectionsDrawer();
|
||||
return;
|
||||
}
|
||||
if (!(await requireAuth())) return;
|
||||
try {
|
||||
const sourceURL = buildHighlightUrl(selectedText);
|
||||
const ref = getSourceReference();
|
||||
const sourceTitle = ref
|
||||
? ref.detail
|
||||
? `${ref.title} — ${ref.detail}`
|
||||
: ref.title
|
||||
: typeof document !== "undefined"
|
||||
? document.title
|
||||
: "";
|
||||
await bookmarks.create({
|
||||
source_url: sourceURL,
|
||||
source_title: sourceTitle,
|
||||
excerpt: selectedText,
|
||||
});
|
||||
toast.success("Bookmark saved", { duration: 2000 });
|
||||
window.dispatchEvent(new CustomEvent("spsays:bookmarks-changed"));
|
||||
clearSelection();
|
||||
} catch (err) {
|
||||
if (err instanceof SocialError && err.status === 402) {
|
||||
toast.error("Bookmark limit reached", {
|
||||
description:
|
||||
"Free plan allows 1 bookmark. We'll save this to a collection instead.",
|
||||
duration: 6000,
|
||||
});
|
||||
openCollectionsDrawer();
|
||||
} else if (err instanceof SocialError) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
toast.error("Could not save bookmark.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyText() {
|
||||
await copy(formatCopyText(selectedText));
|
||||
toast.success("Copied to clipboard", { duration: 1500 });
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
const url = await buildShortHighlightUrl(selectedText);
|
||||
await copy(url);
|
||||
toast.success("Link copied", { duration: 1500 });
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function onSaveToCollection() {
|
||||
setShowMore(false);
|
||||
if (!(await requireAuth())) return;
|
||||
openCollectionsDrawer();
|
||||
}
|
||||
|
||||
// ─── Toolbar actions ──────────────────────────────────────────────────
|
||||
//
|
||||
// Authed users get action-oriented top-level buttons (Post / Bookmark)
|
||||
// and More holds copy/share. Public users get the universally-usable
|
||||
// copy/share at the top and every account-requiring action sits behind
|
||||
// a lock in More.
|
||||
|
||||
const isAuthed = !!user;
|
||||
|
||||
const askAction: ToolbarAction = {
|
||||
key: "ask",
|
||||
label: "Ask Kautilya",
|
||||
icon: <KautilyaIcon className="size-3.5" />,
|
||||
@@ -265,33 +404,9 @@ export function TextSelectionToolbar() {
|
||||
clearSelection();
|
||||
},
|
||||
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
||||
},
|
||||
{
|
||||
key: "share",
|
||||
label: "Share",
|
||||
icon: <Share2 className="size-3.5" />,
|
||||
onClick: () => {
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setShowShare(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
label: copied ? "Copied!" : "Copy",
|
||||
icon: copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
),
|
||||
onClick: () => {
|
||||
copy(formatCopyText(selectedText));
|
||||
},
|
||||
className: copied
|
||||
? "text-green-400 bg-green-400/15"
|
||||
: "text-white/70 hover:bg-white/10 hover:text-white",
|
||||
},
|
||||
{
|
||||
};
|
||||
|
||||
const moreAction: ToolbarAction = {
|
||||
key: "more",
|
||||
label: "More",
|
||||
icon: <MoreHorizontal className="size-3.5" />,
|
||||
@@ -304,9 +419,136 @@ export function TextSelectionToolbar() {
|
||||
});
|
||||
setShowMore(true);
|
||||
},
|
||||
};
|
||||
|
||||
const authedToolbarActions: ToolbarAction[] = [
|
||||
askAction,
|
||||
{
|
||||
key: "post",
|
||||
label: "Post",
|
||||
icon: <Send className="size-3.5" />,
|
||||
onClick: onPost,
|
||||
},
|
||||
{
|
||||
key: "bookmark",
|
||||
label: limitReached ? "Collect" : "Bookmark",
|
||||
icon: limitReached ? (
|
||||
<FolderPlus className="size-3.5" />
|
||||
) : (
|
||||
<BookmarkIcon className="size-3.5" />
|
||||
),
|
||||
onClick: onBookmarkOrCollect,
|
||||
},
|
||||
moreAction,
|
||||
];
|
||||
|
||||
const publicToolbarActions: ToolbarAction[] = [
|
||||
askAction,
|
||||
{
|
||||
key: "copy",
|
||||
label: "Copy",
|
||||
icon: <Copy className="size-3.5" />,
|
||||
onClick: onCopyText,
|
||||
},
|
||||
{
|
||||
key: "share",
|
||||
label: "Share",
|
||||
icon: <Share2 className="size-3.5" />,
|
||||
onClick: onCopyLink,
|
||||
},
|
||||
moreAction,
|
||||
];
|
||||
|
||||
const toolbarActions = isAuthed ? authedToolbarActions : publicToolbarActions;
|
||||
|
||||
const authedMoreItems: MoreItem[] = [
|
||||
{
|
||||
key: "copy-text",
|
||||
label: "Copy Text",
|
||||
description: "Passage with source citation",
|
||||
icon: <Copy className="size-5" />,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
await onCopyText();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
label: "Copy Link",
|
||||
description: "Short URL that highlights this passage",
|
||||
icon: <LinkIcon className="size-5" />,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
await onCopyLink();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "collection",
|
||||
label: "Save to Collection",
|
||||
description: "Organise into a collection",
|
||||
icon: <FolderPlus className="size-5" />,
|
||||
onClick: onSaveToCollection,
|
||||
},
|
||||
{
|
||||
key: "mention",
|
||||
label: "Mention",
|
||||
description: "Tag someone with this quote",
|
||||
icon: <AtSign className="size-5" />,
|
||||
onClick: () => {
|
||||
setShowMore(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const publicMoreItems: MoreItem[] = [
|
||||
{
|
||||
key: "share-as-post",
|
||||
label: "Share as Post",
|
||||
description: "Publish this passage to your feed",
|
||||
icon: <Send className="size-5" />,
|
||||
locked: true,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
await onPost();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "save-to-collection-public",
|
||||
label: "Save to Collection",
|
||||
description: "Organise into a collection",
|
||||
icon: <FolderPlus className="size-5" />,
|
||||
locked: true,
|
||||
onClick: onSaveToCollection,
|
||||
},
|
||||
{
|
||||
key: "bookmark-public",
|
||||
label: "Bookmark",
|
||||
description: "Save your place — always private",
|
||||
icon: <BookmarkIcon className="size-5" />,
|
||||
locked: true,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
await onBookmarkOrCollect();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "mention-public",
|
||||
label: "Mention",
|
||||
description: "Tag someone with this quote",
|
||||
icon: <AtSign className="size-5" />,
|
||||
locked: true,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
if (!(await requireAuth())) return;
|
||||
// TODO: Implement Mention
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const moreItems = isAuthed ? authedMoreItems : publicMoreItems;
|
||||
|
||||
// --- Render ---
|
||||
|
||||
return (
|
||||
@@ -321,34 +563,18 @@ export function TextSelectionToolbar() {
|
||||
open={showMore}
|
||||
position={morePos}
|
||||
isMobile={isMobile}
|
||||
selectedText={selectedText}
|
||||
items={moreItems}
|
||||
onClose={() => {
|
||||
setShowMore(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
onBookmark={() => {
|
||||
setShowMore(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
onSaveToCollection={() => {
|
||||
setShowMore(false);
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setShowCollections(true);
|
||||
}}
|
||||
onMention={() => {
|
||||
setShowMore(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
open={showShare}
|
||||
<ComposePost
|
||||
open={showCompose}
|
||||
onClose={() => {
|
||||
setShowShare(false);
|
||||
setShowCompose(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
@@ -364,6 +590,7 @@ export function TextSelectionToolbar() {
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
selectedText={selectedText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Checkbox — themed replacement for the native `<input type="checkbox" />`.
|
||||
*
|
||||
* The native control is rendered visually hidden (sr-only) so screen
|
||||
* readers and keyboard focus still work the way browsers expect, while
|
||||
* we paint a small rounded box next to it that fills with the primary
|
||||
* color when checked. Fully controlled — caller owns `checked` state.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* <Checkbox checked={ok} onCheckedChange={setOk} />
|
||||
*
|
||||
* <label className="flex items-center gap-2">
|
||||
* <Checkbox checked={ok} onCheckedChange={setOk} />
|
||||
* Accept terms
|
||||
* </label>
|
||||
*/
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(e) => onCheckedChange(e.target.checked)}
|
||||
className="peer absolute inset-0 size-full appearance-none m-0 cursor-pointer disabled:cursor-not-allowed rounded-md"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none size-5 rounded-md border transition-colors",
|
||||
"flex items-center justify-center",
|
||||
checked
|
||||
? "bg-primary border-primary"
|
||||
: "bg-background border-border",
|
||||
"peer-hover:border-foreground/40",
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-1 peer-focus-visible:ring-offset-background",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{checked ? (
|
||||
<Check className="size-3.5 text-primary-foreground" strokeWidth={3} />
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ConfirmModal — palette-themed replacement for native `window.confirm`.
|
||||
*
|
||||
* Mirrors the structure of AuthModal (portal + backdrop + Esc-to-close +
|
||||
* click-outside dismiss + body-scroll lock) but with a single primary
|
||||
* action button. The default tone is "destructive" since that's the
|
||||
* common case (delete this thing), but the `tone` prop swaps to neutral
|
||||
* for less-scary confirms.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const [open, setOpen] = useState(false);
|
||||
* <ConfirmModal
|
||||
* open={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* title="Delete this collection?"
|
||||
* message="Posts inside will stay on your feed."
|
||||
* confirmLabel="Delete"
|
||||
* onConfirm={async () => { await collections.delete(id); ... }}
|
||||
* />
|
||||
*
|
||||
* The `onConfirm` may return a promise — the dialog shows a pending state
|
||||
* and stays open until it resolves, then auto-closes on success. Errors
|
||||
* thrown from onConfirm surface inline below the buttons.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tone = "destructive" | "neutral";
|
||||
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
tone = "destructive",
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
tone?: Tone;
|
||||
/** Called when the user confirms. May return a promise. Throw to keep the modal open with an error message. */
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}) {
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset transient state every time the modal opens — otherwise an error
|
||||
// from a previous attempt would linger on the next open.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setWorking(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !working) onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, working, onClose]);
|
||||
|
||||
// Body-scroll lock while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null);
|
||||
setWorking(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Something went wrong.",
|
||||
);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
|
||||
onClick={() => !working && onClose()}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
aria-describedby={message ? "confirm-modal-message" : undefined}
|
||||
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">
|
||||
<div className="flex items-start gap-3 px-5 pt-5">
|
||||
{tone === "destructive" ? (
|
||||
<span
|
||||
className="shrink-0 size-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
<AlertTriangle className="size-5" />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2
|
||||
id="confirm-modal-title"
|
||||
className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{message ? (
|
||||
<p
|
||||
id="confirm-modal-message"
|
||||
className="mt-1 text-sm text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !working && onClose()}
|
||||
aria-label="Close"
|
||||
className="shrink-0 size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
|
||||
disabled={working}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mx-5 mt-3 text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="px-5 py-4 mt-2 flex items-center justify-end gap-2 border-t border-border bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={working}
|
||||
className="h-10 px-4 rounded-md text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={working}
|
||||
className={cn(
|
||||
"h-10 px-4 rounded-md text-sm font-medium transition-colors disabled:opacity-60",
|
||||
tone === "destructive"
|
||||
? "bg-destructive text-white hover:bg-destructive/90"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
)}
|
||||
>
|
||||
{working ? "Working…" : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Minimal shadcn-style <Label>. We use a native <label> instead of pulling
|
||||
// in @radix-ui/react-label so the dependency footprint stays small.
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none text-foreground select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Toaster — sonner mounted with our palette tokens, so toasts read the
|
||||
* same `--card`, `--foreground`, etc. variables the rest of the site uses.
|
||||
* That keeps notifications on-theme across all six light/dark palettes
|
||||
* without per-palette overrides.
|
||||
*/
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as SonnerToaster } from "sonner";
|
||||
|
||||
export function Toaster() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return (
|
||||
<SonnerToaster
|
||||
theme={(resolvedTheme as "light" | "dark") || "light"}
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
toastOptions={{
|
||||
// Inline styles win over sonner's defaults, but stay token-driven
|
||||
// so the active palette is honoured.
|
||||
style: {
|
||||
background: "var(--card)",
|
||||
color: "var(--foreground)",
|
||||
border: "1px solid var(--border)",
|
||||
},
|
||||
classNames: {
|
||||
toast: "font-[family-name:var(--font-title)]",
|
||||
description: "text-[var(--muted-foreground)]",
|
||||
actionButton: "bg-[var(--primary)] text-[var(--primary-foreground)]",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+13
-1
@@ -397,5 +397,17 @@ export async function getProseChapter(
|
||||
* Get all glossary entries, ordered alphabetically by term.
|
||||
*/
|
||||
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
|
||||
return bffList<GlossaryRecord>("/glossary", { tags: ["glossary"] });
|
||||
const raw = await bffList<{
|
||||
id: string;
|
||||
term: string;
|
||||
definition: string;
|
||||
definition_html: string;
|
||||
}>("/glossary", { tags: ["glossary"] });
|
||||
|
||||
return raw.map((entry) => ({
|
||||
id: entry.id,
|
||||
term: entry.term,
|
||||
definition: entry.definition,
|
||||
definitionHtml: entry.definition_html ?? entry.definition,
|
||||
}));
|
||||
}
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Client-side BFF auth helper.
|
||||
*
|
||||
* All calls run in the browser with `credentials: "include"` so the httpOnly
|
||||
* session cookie set by the BFF on /auth/login is sent back on every
|
||||
* subsequent request.
|
||||
*
|
||||
* For dev: localhost:3000 (Next.js) and localhost:8080 (BFF) are same-site
|
||||
* per browser definition, so SameSite=Lax cookies are accepted.
|
||||
* For prod: set COOKIE_DOMAIN=.prabhupadasays.org on the BFF so the cookie
|
||||
* is shared between prabhupadasays.org and api.prabhupadasays.org.
|
||||
*/
|
||||
|
||||
const BFF_BASE =
|
||||
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
handle: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
plan: "free" | "premium";
|
||||
email_verified_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/** Error shape returned by the BFF envelope. */
|
||||
export class AuthError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
type Envelope<T> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: number; message: string };
|
||||
};
|
||||
|
||||
/** Low-level fetch helper that unwraps the BFF envelope. */
|
||||
async function authFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
|
||||
// 204 No Content
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
|
||||
|
||||
if (!res.ok || body.success === false) {
|
||||
throw new AuthError(res.status, body.error?.message || res.statusText);
|
||||
}
|
||||
return (body.data ?? (undefined as unknown as T)) as T;
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
signUp: (email: string, password: string, name: string) =>
|
||||
authFetch<User>("/auth/signup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
}),
|
||||
|
||||
logIn: (email: string, password: string) =>
|
||||
authFetch<User>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
logOut: () => authFetch<{ ok: boolean }>("/auth/logout", { method: "POST" }),
|
||||
|
||||
/** Returns the current user, or null when not authenticated. */
|
||||
me: async (): Promise<User | null> => {
|
||||
try {
|
||||
return await authFetch<User>("/auth/me");
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError && e.status === 401) return null;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
forgotPassword: (email: string) =>
|
||||
authFetch<{ ok: boolean }>("/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
|
||||
resetPassword: (token: string, newPassword: string) =>
|
||||
authFetch<{ ok: boolean }>("/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, new_password: newPassword }),
|
||||
}),
|
||||
|
||||
verifyEmail: (token: string) =>
|
||||
authFetch<{ ok: boolean }>("/auth/verify-email", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
|
||||
resendVerification: () =>
|
||||
authFetch<{ ok: boolean }>("/auth/resend-verification", { method: "POST" }),
|
||||
|
||||
updateProfile: (
|
||||
name?: string,
|
||||
avatarURL?: string | null,
|
||||
handle?: string,
|
||||
) =>
|
||||
authFetch<User>("/users/me", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
name: name ?? undefined,
|
||||
avatar_url: avatarURL === undefined ? undefined : avatarURL,
|
||||
handle: handle ?? undefined,
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Two-step avatar upload:
|
||||
* 1. Ask the BFF for a presigned R2 PUT URL.
|
||||
* 2. PUT the file directly to R2.
|
||||
* 3. Caller persists the returned publicURL via updateProfile().
|
||||
*/
|
||||
uploadAvatar: async (file: File): Promise<string> => {
|
||||
const { upload_url, public_url } = await authFetch<{
|
||||
upload_url: string;
|
||||
public_url: string;
|
||||
}>("/users/me/avatar-presign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content_type: file.type }),
|
||||
});
|
||||
|
||||
const put = await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
});
|
||||
if (!put.ok) {
|
||||
throw new AuthError(put.status, "avatar upload failed");
|
||||
}
|
||||
return public_url;
|
||||
},
|
||||
};
|
||||
@@ -121,3 +121,4 @@ export async function bffList<T>(
|
||||
const result = await bff<T[]>(path, options);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
|
||||
+31
-1
@@ -287,8 +287,38 @@ export async function getSidebarItems(code: string): Promise<SidebarItem[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Hierarchical books (SVA, SSR, DS) — chapters have nested subChapters
|
||||
const hasHierarchical =
|
||||
meta.chapters &&
|
||||
meta.chapters.length > 0 &&
|
||||
meta.chapters.some((ch) => ch.subChapters && ch.subChapters.length > 0);
|
||||
|
||||
if (hasHierarchical && meta.chapters) {
|
||||
for (const ch of meta.chapters) {
|
||||
// Parent chapter heading (not navigable on its own)
|
||||
items.push({
|
||||
slug: `parent-${ch.number}`,
|
||||
label: `${ch.number}. ${ch.title}`,
|
||||
type: "parent",
|
||||
chapter: ch.number,
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
// Sub-chapters
|
||||
if (ch.subChapters) {
|
||||
for (const sub of ch.subChapters) {
|
||||
items.push({
|
||||
slug: sub.slug,
|
||||
label: sub.title,
|
||||
type: "sub-chapter",
|
||||
chapter: ch.number,
|
||||
parentChapter: ch.number,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chapters — if parts exist, group chapters under part headers
|
||||
if (
|
||||
else if (
|
||||
meta.parts &&
|
||||
meta.parts.length > 0 &&
|
||||
meta.chapters &&
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* OG metadata for the /share/[code] page.
|
||||
*
|
||||
* Given a resolved long path (`/books/bg/18/66?hl=…`), this dispatches to the
|
||||
* correct page-specific OG builder so social-link previews on shortened URLs
|
||||
* look identical to previews on the canonical destination URL.
|
||||
*
|
||||
* The destination page's own `generateMetadata` is not invoked from `/share`,
|
||||
* so we replicate the equivalent fetch + `extractQuoteFromHl` + `ogFor*`
|
||||
* pipeline here. On any failure we fall back to a site-level default so the
|
||||
* link is still scrape-safe.
|
||||
*/
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
buildOgMeta,
|
||||
extractQuoteFromHl,
|
||||
ogDefault,
|
||||
ogForBook,
|
||||
ogForChapter,
|
||||
ogForEssay,
|
||||
ogForJournal,
|
||||
ogForLetter,
|
||||
ogForMagazine,
|
||||
ogForOtherDocument,
|
||||
ogForTranscription,
|
||||
ogForVerse,
|
||||
} from "@/lib/og";
|
||||
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
|
||||
import { bff } from "@/lib/bff";
|
||||
import { getSingleVerse, getProseChapter } from "@/lib/api";
|
||||
import { getLetter } from "@/lib/letters";
|
||||
import { getTranscription } from "@/lib/transcriptions";
|
||||
import {
|
||||
getJournalChapterBySlug,
|
||||
slugToBookCode as journalSlugToBookCode,
|
||||
} from "@/lib/journals";
|
||||
import {
|
||||
getMagazineIssue,
|
||||
slugToBookCode as magazineSlugToBookCode,
|
||||
} from "@/lib/magazines";
|
||||
import {
|
||||
getOtherDocumentChapter,
|
||||
slugToBookCode as otherDocSlugToBookCode,
|
||||
} from "@/lib/other-documents";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Verse-bearing books with a `/{chapter}/{verse}` leaf (no division segment).
|
||||
const VERSE_BOOKS_NO_DIVISION = new Set([
|
||||
"bg",
|
||||
"sb1962",
|
||||
"bs",
|
||||
"mm",
|
||||
"nbs",
|
||||
]);
|
||||
|
||||
// Books whose verse routes have an extra division segment (canto for SB, līlā for CC).
|
||||
const VERSE_BOOKS_WITH_DIVISION = new Set(["sb", "cc"]);
|
||||
|
||||
// Books where the leaf is a single segment (mantra / text slug).
|
||||
const VERSE_BOOKS_SINGLE_SEGMENT = new Set(["iso", "noi"]);
|
||||
|
||||
function splitPath(pathname: string): string[] {
|
||||
return pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((s) => {
|
||||
try {
|
||||
return decodeURIComponent(s);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build OG metadata for a /share/[code] page given the resolved long path.
|
||||
* Never throws — any error falls back to the site default.
|
||||
*/
|
||||
export async function buildShareOgMeta(fullPath: string): Promise<Metadata> {
|
||||
try {
|
||||
const u = new URL("http://x" + fullPath);
|
||||
const parts = splitPath(u.pathname);
|
||||
const sp = u.searchParams;
|
||||
|
||||
if (parts.length === 0) return ogDefault();
|
||||
|
||||
switch (parts[0]) {
|
||||
case "books":
|
||||
return await ogForBookPath(parts, sp);
|
||||
case "letters":
|
||||
return parts[1] ? await ogForLetterPath(parts[1], sp) : ogDefault();
|
||||
case "transcriptions":
|
||||
return parts[1]
|
||||
? await ogForTranscriptionPath(parts[1], sp)
|
||||
: ogDefault();
|
||||
case "essays":
|
||||
return parts[1] ? await ogForEssayPath(parts[1], sp) : ogDefault();
|
||||
case "journals":
|
||||
return parts.length >= 3
|
||||
? await ogForJournalPath(parts[1], parts[2], sp)
|
||||
: ogDefault();
|
||||
case "magazines":
|
||||
return parts.length >= 3
|
||||
? await ogForMagazinePath(parts[1], parts[2], sp)
|
||||
: ogDefault();
|
||||
case "other-documents":
|
||||
return parts.length >= 3
|
||||
? await ogForOtherDocPath(parts[1], parts[2], sp)
|
||||
: ogDefault();
|
||||
default:
|
||||
return ogWithFallbackQuote("Prabhupāda Says", sp);
|
||||
}
|
||||
} catch {
|
||||
return ogDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-type dispatchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ogForBookPath(
|
||||
parts: string[],
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
if (parts.length < 2) return ogDefault();
|
||||
const code = parts[1];
|
||||
|
||||
// Verse with division (SB canto, CC līlā): /books/sb/{canto}/{ch}/{v}
|
||||
if (VERSE_BOOKS_WITH_DIVISION.has(code) && parts.length >= 5) {
|
||||
return await ogForVersePath(code, parts[2], parts[3], parts[4], sp);
|
||||
}
|
||||
|
||||
// Verse without division: /books/{code}/{ch}/{v}
|
||||
if (VERSE_BOOKS_NO_DIVISION.has(code) && parts.length >= 4) {
|
||||
return await ogForVersePath(code, null, parts[2], parts[3], sp);
|
||||
}
|
||||
|
||||
// ISO / NOI: /books/{code}/{slug-or-mantra}
|
||||
if (VERSE_BOOKS_SINGLE_SEGMENT.has(code) && parts.length >= 3) {
|
||||
// Treat the leaf as a "verse"-like slug. extractQuoteFromHl falls back to
|
||||
// prefix-suffix if we can't fetch paragraphs cheaply.
|
||||
return ogWithFallbackQuote(BOOK_NAMES[code] || code.toUpperCase(), sp);
|
||||
}
|
||||
|
||||
// KB: /books/kb/{volume}/{slug}
|
||||
if (code === "kb" && parts.length >= 4) {
|
||||
const volume = Number.parseInt(parts[2], 10);
|
||||
if (Number.isFinite(volume)) {
|
||||
const chapter = await getProseChapter("kb", volume, parts[3]);
|
||||
const paragraphs = chapter?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: BOOK_NAMES.kb,
|
||||
detail: chapter?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForChapter("kb", chapter?.title ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// Generic prose book: /books/{code}/{slug}
|
||||
if (parts.length >= 3) {
|
||||
const chapter = await getProseChapter(code, 0, parts[2]);
|
||||
const paragraphs = chapter?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: BOOK_NAMES[code] || code.toUpperCase(),
|
||||
detail: chapter?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForChapter(code, chapter?.title ?? "");
|
||||
}
|
||||
|
||||
return ogForBook(code);
|
||||
}
|
||||
|
||||
async function ogForVersePath(
|
||||
code: string,
|
||||
division: string | null,
|
||||
chapter: string,
|
||||
verse: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const chapterNum = Number.parseInt(chapter, 10);
|
||||
const verseNum = Number.parseInt(verse, 10);
|
||||
if (!Number.isFinite(chapterNum) || !Number.isFinite(verseNum)) {
|
||||
return ogForBook(code);
|
||||
}
|
||||
|
||||
const v = await getSingleVerse(code, division, chapterNum, verseNum);
|
||||
const paragraphs = v ? [v.translation, ...v.purport] : undefined;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
|
||||
if (quote) {
|
||||
const title = BOOK_NAMES[code] || code.toUpperCase();
|
||||
let detail: string;
|
||||
if (code === "sb" && division) {
|
||||
detail = `Canto ${division}, Chapter ${chapter}, Verse ${verse}`;
|
||||
} else if (code === "cc" && division) {
|
||||
const lila = CC_LILAS[division] || division;
|
||||
detail = `${lila}, Chapter ${chapter}, Verse ${verse}`;
|
||||
} else {
|
||||
detail = `Chapter ${chapter}, Verse ${verse}`;
|
||||
}
|
||||
return buildOgMeta({ title, detail, quote });
|
||||
}
|
||||
|
||||
return ogForVerse(code, {
|
||||
division: division ?? undefined,
|
||||
chapter: chapterNum,
|
||||
verse: verseNum,
|
||||
});
|
||||
}
|
||||
|
||||
async function ogForLetterPath(
|
||||
slug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const letter = await getLetter(slug);
|
||||
const paragraphs = letter?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
const recipient = letter?.recipientDisplay ?? "(Unknown)";
|
||||
const meta =
|
||||
letter?.date && letter?.location
|
||||
? `${letter.date} • ${letter.location}`
|
||||
: letter?.date || letter?.location || undefined;
|
||||
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: `Letter to ${recipient}`,
|
||||
detail: meta,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForLetter(recipient, meta);
|
||||
}
|
||||
|
||||
async function ogForTranscriptionPath(
|
||||
slug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const t = await getTranscription(slug);
|
||||
const paragraphs = t?.content
|
||||
?.filter((b) => b.format === "paragraph" || b.format === "quote")
|
||||
.map((b) => b.text);
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
const name = t?.name ?? "Transcription";
|
||||
const meta =
|
||||
t?.date && t?.place
|
||||
? `${t.date} • ${t.place}`
|
||||
: t?.date || t?.place || undefined;
|
||||
|
||||
if (quote) {
|
||||
return buildOgMeta({ title: name, detail: meta, quote });
|
||||
}
|
||||
return ogForTranscription(name, meta);
|
||||
}
|
||||
|
||||
type EssayDoc = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number | null;
|
||||
group: string | null;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
async function ogForEssayPath(
|
||||
slug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const doc = await bff<EssayDoc>(`/essays/${slug}`);
|
||||
const paragraphs = doc?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: doc?.title ?? "Essay",
|
||||
detail: "Essay by Śrīla Prabhupāda",
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForEssay(doc?.title ?? "Essay");
|
||||
}
|
||||
|
||||
async function ogForJournalPath(
|
||||
bookSlug: string,
|
||||
chapterSlug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const bookCode = journalSlugToBookCode(bookSlug);
|
||||
const ch = await getJournalChapterBySlug(bookCode, chapterSlug);
|
||||
const paragraphs = ch?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
// The fetched chapter type doesn't carry the parent journal title; use the
|
||||
// shared BOOK_NAMES map as the canonical display name.
|
||||
const journalTitle = BOOK_NAMES[bookCode] ?? bookSlug;
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: journalTitle,
|
||||
detail: ch?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForJournal(journalTitle, ch?.title);
|
||||
}
|
||||
|
||||
async function ogForMagazinePath(
|
||||
bookSlug: string,
|
||||
issueSlug: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const bookCode = magazineSlugToBookCode(bookSlug);
|
||||
const issue = await getMagazineIssue(bookCode, issueSlug);
|
||||
const paragraphs = issue?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
const magazineTitle = BOOK_NAMES[bookCode] ?? bookSlug;
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: magazineTitle,
|
||||
detail: issue?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForMagazine(magazineTitle, issue?.title);
|
||||
}
|
||||
|
||||
async function ogForOtherDocPath(
|
||||
bookSlug: string,
|
||||
chapterPart: string,
|
||||
sp: URLSearchParams,
|
||||
): Promise<Metadata> {
|
||||
const bookCode = otherDocSlugToBookCode(bookSlug);
|
||||
const chapterNum = Number.parseInt(chapterPart, 10);
|
||||
const docTitle = BOOK_NAMES[bookCode] ?? bookSlug;
|
||||
if (!Number.isFinite(chapterNum)) {
|
||||
return ogForOtherDocument(docTitle);
|
||||
}
|
||||
const ch = await getOtherDocumentChapter(bookCode, chapterNum);
|
||||
const paragraphs = ch?.paragraphs;
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
if (quote) {
|
||||
return buildOgMeta({
|
||||
title: docTitle,
|
||||
detail: ch?.title,
|
||||
quote,
|
||||
});
|
||||
}
|
||||
return ogForOtherDocument(docTitle, ch?.title);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ogWithFallbackQuote(
|
||||
title: string,
|
||||
sp: URLSearchParams,
|
||||
): Metadata {
|
||||
// No paragraphs available — fall back to prefix … suffix reconstruction.
|
||||
const quote = extractQuoteFromHl(sp);
|
||||
if (quote) {
|
||||
return buildOgMeta({ title, quote });
|
||||
}
|
||||
return ogDefault();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Short link client.
|
||||
*
|
||||
* - `createShortLink` runs in the browser and POSTs to `/api/share`, a Next.js
|
||||
* route handler that proxies to the BFF. We can't call the BFF directly
|
||||
* from the browser because `BFF_URL` is not exposed to the client bundle
|
||||
* (no `NEXT_PUBLIC_` prefix). Same-origin proxy also avoids CORS.
|
||||
*
|
||||
* - `resolveShortLink` is only called from the `/share/[code]` server
|
||||
* component, so it can use `bff()` directly with the server-side env var.
|
||||
*/
|
||||
|
||||
import { bff } from "@/lib/bff";
|
||||
|
||||
// Module-scoped cache: a single user shouldn't generate a fresh code every
|
||||
// time they re-click Share on the same selection. Keyed by the relative path
|
||||
// (including ?hl=…), this returns the same promise for repeat calls.
|
||||
const createCache = new Map<string, Promise<string | null>>();
|
||||
|
||||
type ResolveResponse = { url: string };
|
||||
|
||||
/**
|
||||
* Create a short code for a same-site relative path (`/books/bg/18/66?hl=…`).
|
||||
* Returns `null` on any failure — caller should fall back to the long URL.
|
||||
*/
|
||||
export function createShortLink(longPath: string): Promise<string | null> {
|
||||
const cached = createCache.get(longPath);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = fetch("/api/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ url: longPath }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json().catch(() => null);
|
||||
const code = json?.data?.code;
|
||||
return typeof code === "string" ? code : null;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
createCache.set(longPath, promise);
|
||||
// If the request fails, evict so the next click can retry instead of
|
||||
// returning the cached null forever.
|
||||
promise.then((code) => {
|
||||
if (code === null) createCache.delete(longPath);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a short code to its full long path. Returns `null` when the code is
|
||||
* not found, expired, disabled, or the BFF is unreachable.
|
||||
*/
|
||||
export async function resolveShortLink(code: string): Promise<string | null> {
|
||||
try {
|
||||
const r = await bff<ResolveResponse>(`/share/${encodeURIComponent(code)}`, {
|
||||
revalidate: 0,
|
||||
});
|
||||
return r?.url ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Browser-side BFF client for the social features: posts, bookmarks,
|
||||
* collections, public profiles.
|
||||
*
|
||||
* All requests run with `credentials: "include"` so the auth cookie set
|
||||
* by /auth/login is sent along automatically. Errors come back as
|
||||
* `SocialError` instances carrying the HTTP status code and the message
|
||||
* from the BFF envelope.
|
||||
*
|
||||
* Endpoint paths mirror the BFF routes 1:1 — there's no Next.js proxy
|
||||
* for these in the current setup (BFF is on the same registrable domain
|
||||
* in prod, and localhost is same-site in dev).
|
||||
*/
|
||||
|
||||
const BFF_BASE =
|
||||
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||
|
||||
export class SocialError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SocialError";
|
||||
}
|
||||
}
|
||||
|
||||
type Envelope<T> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: number; message: string };
|
||||
meta?: { page: number; limit: number; total: number; total_pages: number };
|
||||
};
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
if (res.status === 204) return undefined as T;
|
||||
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
|
||||
if (!res.ok || body.success === false) {
|
||||
throw new SocialError(res.status, body.error?.message || res.statusText);
|
||||
}
|
||||
return (body.data ?? (undefined as unknown as T)) as T;
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type PostImage = { id: string; url: string; position: number };
|
||||
|
||||
export type Post = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
caption: string;
|
||||
source_url: string;
|
||||
source_title: string;
|
||||
images: PostImage[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
author_name?: string;
|
||||
author_handle?: string;
|
||||
author_avatar_url?: string | null;
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
source_url: string;
|
||||
source_title: string;
|
||||
excerpt: string | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Collection = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
cover_url: string | null;
|
||||
is_public: boolean;
|
||||
item_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PublicProfile = {
|
||||
handle: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
joined_at: string;
|
||||
post_count: number;
|
||||
public_collections: Collection[];
|
||||
};
|
||||
|
||||
// ─── Posts ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const posts = {
|
||||
create(input: {
|
||||
caption: string;
|
||||
source_url: string;
|
||||
source_title?: string;
|
||||
image_urls?: string[];
|
||||
}) {
|
||||
return call<Post>("/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
caption: input.caption,
|
||||
source_url: input.source_url,
|
||||
source_title: input.source_title ?? "",
|
||||
image_urls: input.image_urls ?? [],
|
||||
}),
|
||||
});
|
||||
},
|
||||
get(id: string) {
|
||||
return call<Post>(`/posts/${id}`);
|
||||
},
|
||||
delete(id: string) {
|
||||
return call<void>(`/posts/${id}`, { method: "DELETE" });
|
||||
},
|
||||
/** Swap the post's image set. Empty array removes all images. */
|
||||
replaceImages(id: string, image_urls: string[]) {
|
||||
return call<Post>(`/posts/${id}/images`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ image_urls }),
|
||||
});
|
||||
},
|
||||
mine() {
|
||||
return call<Post[]>("/posts/mine");
|
||||
},
|
||||
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
|
||||
async uploadImage(file: File): Promise<string> {
|
||||
const { upload_url, public_url } = await call<{
|
||||
upload_url: string;
|
||||
public_url: string;
|
||||
}>("/posts/image-presign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content_type: file.type }),
|
||||
});
|
||||
const put = await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
});
|
||||
if (!put.ok) throw new SocialError(put.status, "image upload failed");
|
||||
return public_url;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Bookmarks ───────────────────────────────────────────────────────────
|
||||
|
||||
export const bookmarks = {
|
||||
create(input: {
|
||||
source_url: string;
|
||||
source_title?: string;
|
||||
excerpt?: string;
|
||||
note?: string;
|
||||
}) {
|
||||
return call<Bookmark>("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
source_url: input.source_url,
|
||||
source_title: input.source_title ?? "",
|
||||
excerpt: input.excerpt ?? null,
|
||||
note: input.note ?? null,
|
||||
}),
|
||||
});
|
||||
},
|
||||
list() {
|
||||
return call<Bookmark[]>("/bookmarks");
|
||||
},
|
||||
delete(id: string) {
|
||||
return call<void>(`/bookmarks/${id}`, { method: "DELETE" });
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Collections ─────────────────────────────────────────────────────────
|
||||
|
||||
export const collections = {
|
||||
create(input: {
|
||||
title: string;
|
||||
description?: string;
|
||||
cover_url?: string;
|
||||
is_public?: boolean;
|
||||
}) {
|
||||
return call<Collection>("/collections", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
cover_url: input.cover_url ?? null,
|
||||
is_public: !!input.is_public,
|
||||
}),
|
||||
});
|
||||
},
|
||||
update(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
cover_url: string;
|
||||
is_public: boolean;
|
||||
}>,
|
||||
) {
|
||||
return call<Collection>(`/collections/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
title: input.title ?? undefined,
|
||||
description: input.description ?? undefined,
|
||||
cover_url: input.cover_url === undefined ? undefined : input.cover_url,
|
||||
is_public: input.is_public ?? undefined,
|
||||
}),
|
||||
});
|
||||
},
|
||||
delete(id: string) {
|
||||
return call<void>(`/collections/${id}`, { method: "DELETE" });
|
||||
},
|
||||
mine() {
|
||||
return call<Collection[]>("/collections");
|
||||
},
|
||||
/** Public-or-private depending on visibility. Anonymous viewers get 403 on private collections. */
|
||||
get(id: string) {
|
||||
return call<{ collection: Collection; posts: Post[] }>(`/collections/${id}`);
|
||||
},
|
||||
addItem(collectionID: string, postID: string) {
|
||||
return call<{ ok: boolean }>(`/collections/${collectionID}/items`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ post_id: postID }),
|
||||
});
|
||||
},
|
||||
removeItem(collectionID: string, postID: string) {
|
||||
return call<void>(`/collections/${collectionID}/items/${postID}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
/** Returns the ids of the caller's collections that already contain
|
||||
* the given post. The save-to-collection drawer uses this to disable
|
||||
* rows so the post can't be added a second time. */
|
||||
async containingPost(postID: string): Promise<string[]> {
|
||||
const r = await call<{ collection_ids: string[] }>(
|
||||
`/collections/containing/${postID}`,
|
||||
);
|
||||
return r.collection_ids ?? [];
|
||||
},
|
||||
/** Rewrite the position of every post in `post_ids` to match the array
|
||||
* order. The BFF runs the updates in a single tx — readers see either
|
||||
* the old order or the new one, never a half-applied state. */
|
||||
reorderItems(collectionID: string, post_ids: string[]) {
|
||||
return call<void>(`/collections/${collectionID}/items/order`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ post_ids }),
|
||||
});
|
||||
},
|
||||
/** R2 presigned PUT for the collection cover. */
|
||||
async uploadCover(file: File): Promise<string> {
|
||||
const { upload_url, public_url } = await call<{
|
||||
upload_url: string;
|
||||
public_url: string;
|
||||
}>("/collections/cover-presign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content_type: file.type }),
|
||||
});
|
||||
const put = await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
});
|
||||
if (!put.ok) throw new SocialError(put.status, "cover upload failed");
|
||||
return public_url;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Public profile ──────────────────────────────────────────────────────
|
||||
|
||||
export const profile = {
|
||||
get(handle: string) {
|
||||
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
|
||||
},
|
||||
feed(handle: string, limit = 20, offset = 0) {
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
|
||||
},
|
||||
};
|
||||
@@ -247,6 +247,128 @@ export function getSourceReference(): {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-only variant of getSourceReference — pure function, safe on server.
|
||||
*
|
||||
* For `/books/...` paths we can derive a clean citation (e.g.
|
||||
* "Bhagavad-gītā As It Is — Chapter 1, Verse 1") from the path alone.
|
||||
* Mirrors the logic in `lib/og.ts`'s `ogForVerse` so post cards display
|
||||
* the same reference the OG image carries.
|
||||
*
|
||||
* For non-book paths the title lives in the page DOM (h1) which we don't
|
||||
* have at card-render time, so we return null — callers should fall back
|
||||
* to whatever `source_title` was saved at compose time.
|
||||
*/
|
||||
export function referenceFromUrl(
|
||||
sourceUrl: string,
|
||||
): { title: string; detail: string | null } | null {
|
||||
if (!sourceUrl) return null;
|
||||
let path: string;
|
||||
try {
|
||||
if (sourceUrl.startsWith("/")) {
|
||||
path = sourceUrl.split("?")[0];
|
||||
} else {
|
||||
const u = new URL(sourceUrl);
|
||||
path = u.pathname;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts[0] !== "books" || parts.length < 2) return null;
|
||||
|
||||
const code = parts[1];
|
||||
const bookName = BOOK_NAMES[code] || code.toUpperCase();
|
||||
|
||||
// Verse-based: /books/bg/[chapter]/[verse]
|
||||
if (code === "bg" && parts.length >= 4) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
|
||||
};
|
||||
}
|
||||
// SB: /books/sb/[canto]/[chapter]/[verse]
|
||||
if (code === "sb" && parts.length >= 5) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Canto ${parts[2]}, Chapter ${parts[3]}, Verse ${parts[4]}`,
|
||||
};
|
||||
}
|
||||
// CC: /books/cc/[lila]/[chapter]/[verse]
|
||||
if (code === "cc" && parts.length >= 5) {
|
||||
const lila = CC_LILAS[parts[2]] || parts[2];
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `${lila}, Chapter ${parts[3]}, Verse ${parts[4]}`,
|
||||
};
|
||||
}
|
||||
// CC lila index: /books/cc/[lila]
|
||||
if (code === "cc" && parts.length === 3) {
|
||||
const lila = CC_LILAS[parts[2]] || parts[2];
|
||||
return { title: bookName, detail: lila };
|
||||
}
|
||||
// SB1962: /books/sb1962/[chapter]/[verse]
|
||||
if (code === "sb1962" && parts.length >= 4) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
|
||||
};
|
||||
}
|
||||
// ISO / NOI: /books/{code}/[mantra]
|
||||
if ((code === "iso" || code === "noi") && parts.length >= 3) {
|
||||
const num = parts[2];
|
||||
const label = code === "iso" ? "Mantra" : "Text";
|
||||
return { title: bookName, detail: `${label} ${num}` };
|
||||
}
|
||||
// BS/MM/NBS verse: /books/{code}/[chapter]/[verse]
|
||||
if (
|
||||
(code === "bs" || code === "mm" || code === "nbs") &&
|
||||
parts.length >= 4
|
||||
) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
|
||||
};
|
||||
}
|
||||
// KB: /books/kb/[volume]/[slug]
|
||||
if (code === "kb" && parts.length >= 4) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Volume ${parts[2]}, Chapter ${parts[3]}`,
|
||||
};
|
||||
}
|
||||
// Generic prose: /books/{code}/[chapter]
|
||||
if (parts.length >= 3) {
|
||||
const chSlug = parts[2];
|
||||
const chNum = parseInt(chSlug, 10);
|
||||
const detail = isNaN(chNum)
|
||||
? chSlug.replace(/-/g, " ")
|
||||
: `Chapter ${chNum}`;
|
||||
return { title: bookName, detail };
|
||||
}
|
||||
// Book index
|
||||
return { title: bookName, detail: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a one-line citation string for a post/bookmark card.
|
||||
*
|
||||
* For book URLs we always prefer the URL-derived reference (canonical
|
||||
* format, no stale data). For non-book content (letters, essays, etc.)
|
||||
* the saved `source_title` is the source of truth.
|
||||
*/
|
||||
export function displaySourceLabel(
|
||||
sourceUrl: string,
|
||||
storedTitle?: string | null,
|
||||
): string {
|
||||
const ref = referenceFromUrl(sourceUrl);
|
||||
if (ref) {
|
||||
return ref.detail ? `${ref.title} — ${ref.detail}` : ref.title;
|
||||
}
|
||||
return storedTitle?.trim() || "Source";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format selected text with source attribution appended.
|
||||
*/
|
||||
|
||||
+702
-18
@@ -37,6 +37,38 @@ export function encodeTextFragment(text: string): string {
|
||||
return `${prefixWords}§${suffixWords}§${len}`;
|
||||
}
|
||||
|
||||
// Map typographical (curly) quotes to ASCII so matching is resilient to
|
||||
// publisher-style differences. Typesense often indexes a straight-quote form
|
||||
// of the text while the rendered DOM uses curly quotes (or vice versa). Applied
|
||||
// to both sides of every match so prefix/suffix and DOM text agree regardless
|
||||
// of which form either one carries. Length-preserving (1 char → 1 char), so
|
||||
// it does not disturb the `fragment.length` window used for disambiguation.
|
||||
function normalizeQuotes(s: string): string {
|
||||
return s
|
||||
.replace(/[“”„‟″«»]/g, '"')
|
||||
.replace(/[‘’‚‛′]/g, "'");
|
||||
}
|
||||
|
||||
// Strip IAST / Vedic diacritics for matching. Decomposes via NFD then drops
|
||||
// codepoints in the Combining Diacritical Marks block. Necessary because
|
||||
// Typesense returns snippets from the `_ascii` shadow field (e.g. "Krsna")
|
||||
// while the rendered DOM uses the original IAST form ("Kṛṣṇa") — without
|
||||
// this normalization, a search-result link to KB 1.34 with prefix "Krsna is
|
||||
// so beautiful" never matches the "Kṛṣṇa is so beautiful" in the DOM.
|
||||
//
|
||||
// Length-preserving for NFC input (one NFC char → one ASCII char): NFD
|
||||
// expands an accented char into base + combining mark, the regex deletes
|
||||
// only the combining mark, and the base is left. So position maps built
|
||||
// from the original collapsed string remain valid after this transform.
|
||||
function stripDiacritics(s: string): string {
|
||||
return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
|
||||
}
|
||||
|
||||
// Combined normalization used on both sides of every match.
|
||||
function normalizeForMatch(s: string): string {
|
||||
return stripDiacritics(normalizeQuotes(s.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a text fragment from URL params.
|
||||
* Supports `§` (current), `|` (v2 legacy), and `,` (v1 legacy) delimiters.
|
||||
@@ -78,17 +110,24 @@ export function extractMatchedText(
|
||||
fragment: { prefix: string; suffix: string; length: number },
|
||||
paragraphs: string[],
|
||||
): string | null {
|
||||
// Strip HTML tags to get plain text
|
||||
// Strip HTML tags and entities to get plain text. The entity regex must be
|
||||
// strict — `&[^;]+;` would greedily pair a stray `&` (e.g. "24 & 25 Oct")
|
||||
// with a far-away `;` (e.g. "my beloved son;"), eating everything between
|
||||
// them. Only match real entities: named (&), decimal ('), hex (').
|
||||
const plainParagraphs = paragraphs.map((p) =>
|
||||
p.replace(/<[^>]*>/g, "").replace(/&[^;]+;/g, " "),
|
||||
p
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);/g, " "),
|
||||
);
|
||||
|
||||
// Build a single string with paragraph breaks
|
||||
const fullText = plainParagraphs.join(" ");
|
||||
const normalized = fullText.replace(/\s+/g, " ");
|
||||
const normalizedLower = normalized.toLowerCase();
|
||||
const normalizedLower = normalizeForMatch(normalized);
|
||||
|
||||
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase();
|
||||
const normalizedPrefix = normalizeForMatch(
|
||||
fragment.prefix.replace(/\s+/g, " "),
|
||||
);
|
||||
let searchFrom = 0;
|
||||
let matchStart = -1;
|
||||
|
||||
@@ -97,9 +136,9 @@ export function extractMatchedText(
|
||||
if (idx === -1) break;
|
||||
|
||||
if (fragment.suffix) {
|
||||
const normalizedSuffix = fragment.suffix
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
const normalizedSuffix = normalizeForMatch(
|
||||
fragment.suffix.replace(/\s+/g, " "),
|
||||
);
|
||||
const expectedEnd = idx + fragment.length;
|
||||
const suffixSearchStart = Math.max(
|
||||
idx,
|
||||
@@ -135,6 +174,114 @@ export function extractMatchedText(
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most relevant snippet of text from a search result snippet HTML
|
||||
* (which contains `<mark>...</mark>` tags around the parts Typesense matched).
|
||||
*
|
||||
* Returns the continuous span from the first `<mark>` to the last `<mark>`,
|
||||
* with all HTML tags stripped — i.e. the actual text the user searched for as
|
||||
* it appears in the source document. Returns `null` if there are no marks or
|
||||
* the result would be empty.
|
||||
*
|
||||
* Used to build a `?hl=` URL when navigating from a search result, so the
|
||||
* destination page highlights the matched phrase.
|
||||
*
|
||||
* Examples:
|
||||
* "...just <mark>surrender unto</mark> Me..." → "surrender unto"
|
||||
* "<mark>letter</mark> to <mark>Tusta</mark> <mark>Krsna</mark>" → "letter to Tusta Krsna"
|
||||
*/
|
||||
export function extractMatchedTextFromSnippet(
|
||||
snippetHtml: string,
|
||||
): string | null {
|
||||
if (!snippetHtml) return null;
|
||||
|
||||
const matches = Array.from(
|
||||
snippetHtml.matchAll(/<mark[^>]*>([\s\S]*?)<\/mark>/gi),
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const first = matches[0];
|
||||
const last = matches[matches.length - 1];
|
||||
|
||||
const firstStart = first.index ?? 0;
|
||||
const lastEnd = (last.index ?? 0) + last[0].length;
|
||||
|
||||
const span = snippetHtml.slice(firstStart, lastEnd);
|
||||
const plain = normalizeQuotes(
|
||||
decodeSnippetEntities(span.replace(/<[^>]*>/g, ""))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
);
|
||||
|
||||
if (!plain) return null;
|
||||
|
||||
// If the span between first and last mark is unreasonably long
|
||||
// (e.g. snippet has marks at far ends of a long passage), fall back to
|
||||
// just the first marked term — that's still a real match in the doc.
|
||||
if (plain.length > 200) {
|
||||
const firstPlain = normalizeQuotes(
|
||||
decodeSnippetEntities(first[1].replace(/<[^>]*>/g, ""))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
);
|
||||
return firstPlain || null;
|
||||
}
|
||||
|
||||
return plain;
|
||||
}
|
||||
|
||||
// Decode the HTML entities Typesense commonly emits in match snippets.
|
||||
// Curly-quote entities (“ etc.) must be decoded explicitly — if they
|
||||
// fall through to the catch-all that maps unknown entities to a space, the
|
||||
// resulting `?hl=` fragment loses characters that are present in the DOM,
|
||||
// and findAndHighlightText fails to locate the highlight.
|
||||
function decodeSnippetEntities(s: string): string {
|
||||
return s
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/&(?:quot|#34|#x22);/gi, '"')
|
||||
.replace(/&(?:apos|#39|#x27);/gi, "'")
|
||||
.replace(/&(?:ldquo|rdquo|bdquo|#8220|#8221|#8222);/gi, '"')
|
||||
.replace(/&(?:lsquo|rsquo|sbquo|#8216|#8217|#8218);/gi, "'")
|
||||
.replace(/&[a-z]+\d*;/gi, " ")
|
||||
.replace(/&#x?[0-9a-f]+;/gi, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a result URL with `?hl=` appended so the destination page highlights
|
||||
* the matched phrase. Preserves any other query params already on the URL.
|
||||
*
|
||||
* Returns the original URL unchanged if no usable highlight text could be
|
||||
* extracted from the snippet.
|
||||
*/
|
||||
export function buildSearchResultUrl(
|
||||
resultUrl: string,
|
||||
snippetHtml: string,
|
||||
): string {
|
||||
const matched = extractMatchedTextFromSnippet(snippetHtml);
|
||||
if (!matched) return resultUrl;
|
||||
|
||||
const fragment = encodeTextFragment(matched);
|
||||
if (!fragment) return resultUrl;
|
||||
|
||||
// resultUrl is a relative path like "/books/bg/18/66"; we just need to
|
||||
// splice in the `hl` param without disturbing any existing query string.
|
||||
const [path, queryAndHash = ""] = resultUrl.split(/(?=[?#])/, 2);
|
||||
const hashIdx = queryAndHash.indexOf("#");
|
||||
const existingQuery =
|
||||
hashIdx >= 0 ? queryAndHash.slice(0, hashIdx) : queryAndHash;
|
||||
const hash = hashIdx >= 0 ? queryAndHash.slice(hashIdx) : "";
|
||||
|
||||
const params = new URLSearchParams(
|
||||
existingQuery.startsWith("?") ? existingQuery.slice(1) : existingQuery,
|
||||
);
|
||||
params.set("hl", fragment);
|
||||
|
||||
return `${path}?${params.toString()}${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL with highlight fragment for the selected text.
|
||||
*/
|
||||
@@ -149,6 +296,279 @@ export function buildHighlightUrl(selectedText: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append (or replace) the `?hl=…` fragment on a path-only URL.
|
||||
*
|
||||
* Unlike `buildHighlightUrl`, this works without `window` — useful for
|
||||
* server-rendered links and for rebuilding bookmark/post source URLs at
|
||||
* render time (e.g. a bookmark saved by an older client without an `?hl=`
|
||||
* still scrolls to its text when you click it, because we synthesise the
|
||||
* fragment from the stored excerpt/caption).
|
||||
*
|
||||
* Accepts:
|
||||
* path — same-origin path, may already include a `?query` (e.g.
|
||||
* `/books/bg/1/1` or `/books/bg/1/1?foo=bar`).
|
||||
* text — the text we want to highlight on the destination page.
|
||||
*
|
||||
* Returns the path with `hl` set to the encoded fragment. If `text`
|
||||
* produces no fragment (e.g. empty string) returns the input unchanged.
|
||||
*/
|
||||
export function appendHighlight(path: string, text: string): string {
|
||||
if (!text || !path) return path;
|
||||
const fragment = encodeTextFragment(text);
|
||||
if (!fragment) return path;
|
||||
|
||||
const hashIdx = path.indexOf("#");
|
||||
const hash = hashIdx >= 0 ? path.slice(hashIdx) : "";
|
||||
const noHash = hashIdx >= 0 ? path.slice(0, hashIdx) : path;
|
||||
|
||||
const qIdx = noHash.indexOf("?");
|
||||
const head = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash;
|
||||
const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : "";
|
||||
const params = new URLSearchParams(query);
|
||||
params.delete("hl");
|
||||
params.set("hl", fragment);
|
||||
return `${head}?${params.toString()}${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `path` already carries an `?hl=` query param.
|
||||
* Cheap string check — avoids a URL parse on the render hot path.
|
||||
*/
|
||||
export function hasHighlight(path: string): boolean {
|
||||
if (!path) return false;
|
||||
return /[?&]hl=/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a shortened, shareable URL for the current selection. POSTs the
|
||||
* `?hl=…` URL to the BFF, which stores the mapping in Postgres (Redis-queued)
|
||||
* and returns a short code. The destination `/share/<code>` page resolves
|
||||
* back to the same `?hl=…` URL the existing TextHighlighter already knows
|
||||
* how to process — no change to highlighting behavior.
|
||||
*
|
||||
* Falls back to the long URL on any failure so sharing never silently breaks.
|
||||
*
|
||||
* `buildHighlightUrl` (sync) is intentionally left in place so other callers
|
||||
* (e.g. search-result navigation) keep working unchanged.
|
||||
*/
|
||||
export async function buildShortHighlightUrl(
|
||||
selectedText: string,
|
||||
): Promise<string> {
|
||||
const longUrl = buildHighlightUrl(selectedText);
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(longUrl);
|
||||
} catch {
|
||||
return longUrl;
|
||||
}
|
||||
const relativePath = u.pathname + u.search + u.hash;
|
||||
|
||||
const { createShortLink } = await import("@/lib/share");
|
||||
const code = await createShortLink(relativePath);
|
||||
if (!code) return longUrl;
|
||||
|
||||
return new URL(`/share/${code}`, u.origin).toString();
|
||||
}
|
||||
|
||||
// HTML block-level tags. When concatenating text nodes for matching, we
|
||||
// insert a synthetic space at boundaries between *different* block-level
|
||||
// ancestors so cross-paragraph selections still match (React often
|
||||
// renders adjacent <p>s with no whitespace between them).
|
||||
//
|
||||
// Crucially, crossing an *inline* boundary (e.g. <p>In <i>Caitanya</i>
|
||||
// (...) it</p>) must NOT insert a space — otherwise the prefix
|
||||
// "In Caitanya (X) it" gets reconstructed as "In Caitanya ( X ) it" and
|
||||
// the match fails on every passage with italicised Sanskrit terms.
|
||||
const BLOCK_TAGS = new Set([
|
||||
"P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6",
|
||||
"LI", "UL", "OL", "BLOCKQUOTE", "SECTION", "ARTICLE",
|
||||
"HEADER", "FOOTER", "NAV", "ASIDE", "MAIN", "FIGURE",
|
||||
"FIGCAPTION", "PRE", "TR", "TD", "TH", "TABLE", "ADDRESS",
|
||||
"HR", "DL", "DT", "DD", "BODY", "HTML",
|
||||
]);
|
||||
|
||||
function nearestBlockAncestor(el: Element | null): Element | null {
|
||||
let cur: Element | null = el;
|
||||
while (cur) {
|
||||
if (BLOCK_TAGS.has(cur.tagName)) return cur;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a DOM Range matching a decoded text fragment WITHOUT modifying the
|
||||
* DOM. Returns null when no match is found. Used by the bookmark-indicator
|
||||
* overlay: we need each bookmark's bounding rect to anchor a floating
|
||||
* icon, but we don't want a second wrapping pass on top of whatever
|
||||
* findAndHighlightText (`?hl=`) might already have done on the same page.
|
||||
*
|
||||
* Shares the same prefix+suffix+length normalisation that
|
||||
* findAndHighlightText uses so a bookmark's saved fragment matches the
|
||||
* same DOM text the toolbar selected.
|
||||
*/
|
||||
export function findTextRange(fragment: {
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
length: number;
|
||||
}): Range | null {
|
||||
const contentAreas = document.querySelectorAll(
|
||||
"main, [data-slot=card-content], article, [data-content-area]",
|
||||
);
|
||||
if (contentAreas.length === 0) return null;
|
||||
|
||||
const allTextNodes: Text[] = [];
|
||||
contentAreas.forEach((area) => {
|
||||
const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
if (node.textContent && node.textContent.trim().length > 0) {
|
||||
allTextNodes.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Same segment/normalisation pipeline as findAndHighlightText; see comments
|
||||
// there. We trade a little duplication for guaranteed parity in matching.
|
||||
const segments: { node: Text; start: number; end: number }[] = [];
|
||||
let rawText = "";
|
||||
let normText = "";
|
||||
const posMap: number[] = [];
|
||||
let prevParent: Element | null = null;
|
||||
let prevBlock: Element | null = null;
|
||||
for (const node of allTextNodes) {
|
||||
const text = node.textContent || "";
|
||||
const parent = node.parentElement;
|
||||
const block = nearestBlockAncestor(parent);
|
||||
if (
|
||||
prevParent &&
|
||||
prevBlock !== block &&
|
||||
normText.length > 0 &&
|
||||
!normText.endsWith(" ") &&
|
||||
!text.startsWith(" ")
|
||||
) {
|
||||
posMap.push(rawText.length);
|
||||
normText += " ";
|
||||
}
|
||||
prevParent = parent;
|
||||
prevBlock = block;
|
||||
const segStart = rawText.length;
|
||||
segments.push({ node, start: segStart, end: segStart + text.length });
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
posMap.push(rawText.length + i);
|
||||
normText += text[i];
|
||||
}
|
||||
rawText += text;
|
||||
}
|
||||
let collapsedNorm = "";
|
||||
const collapsedPosMap: number[] = [];
|
||||
let lastWasSpace = false;
|
||||
for (let i = 0; i < normText.length; i++) {
|
||||
const ch = normText[i];
|
||||
const isSpace = /\s/.test(ch);
|
||||
if (isSpace) {
|
||||
if (!lastWasSpace) {
|
||||
collapsedPosMap.push(posMap[i]);
|
||||
collapsedNorm += " ";
|
||||
}
|
||||
lastWasSpace = true;
|
||||
} else {
|
||||
collapsedPosMap.push(posMap[i]);
|
||||
collapsedNorm += ch;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
}
|
||||
const normalizedFullLower = normalizeForMatch(collapsedNorm);
|
||||
const normalizedPrefix = normalizeForMatch(
|
||||
fragment.prefix.replace(/\s+/g, " "),
|
||||
);
|
||||
let searchFrom = 0;
|
||||
let matchStartNorm = -1;
|
||||
while (searchFrom < normalizedFullLower.length) {
|
||||
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
|
||||
if (idx === -1) break;
|
||||
if (fragment.suffix) {
|
||||
const normalizedSuffix = normalizeForMatch(
|
||||
fragment.suffix.replace(/\s+/g, " "),
|
||||
);
|
||||
const expectedEndNorm = idx + fragment.length;
|
||||
const suffixSearchStart = Math.max(
|
||||
idx,
|
||||
expectedEndNorm - normalizedSuffix.length - 50,
|
||||
);
|
||||
const suffixSearchEnd = Math.min(
|
||||
normalizedFullLower.length,
|
||||
expectedEndNorm + 50,
|
||||
);
|
||||
const searchWindow = normalizedFullLower.slice(
|
||||
suffixSearchStart,
|
||||
suffixSearchEnd,
|
||||
);
|
||||
if (searchWindow.includes(normalizedSuffix)) {
|
||||
matchStartNorm = idx;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
matchStartNorm = idx;
|
||||
break;
|
||||
}
|
||||
searchFrom = idx + 1;
|
||||
}
|
||||
if (matchStartNorm === -1) return null;
|
||||
const matchEndNorm = Math.min(
|
||||
matchStartNorm + fragment.length,
|
||||
collapsedNorm.length,
|
||||
);
|
||||
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
|
||||
let matchEnd =
|
||||
matchEndNorm < collapsedPosMap.length
|
||||
? collapsedPosMap[matchEndNorm]
|
||||
: rawText.length;
|
||||
|
||||
// Defensive raw-text alignment — same logic as the other matchers.
|
||||
// Use fragment.length for range fragments so the selection isn't
|
||||
// collapsed to just the prefix.
|
||||
const rawLower = rawText.toLowerCase();
|
||||
const prefixLower = fragment.prefix.toLowerCase();
|
||||
const directIdx = rawLower.indexOf(prefixLower);
|
||||
if (directIdx >= 0) {
|
||||
matchStart = directIdx;
|
||||
const endByLength =
|
||||
fragment.suffix && fragment.length > fragment.prefix.length
|
||||
? directIdx + fragment.length
|
||||
: directIdx + fragment.prefix.length;
|
||||
matchEnd = Math.min(endByLength, rawText.length);
|
||||
}
|
||||
|
||||
// Map raw-text offsets back to (textNode, offsetInNode) anchors for the Range.
|
||||
let startAnchor: { node: Text; offset: number } | null = null;
|
||||
let endAnchor: { node: Text; offset: number } | null = null;
|
||||
for (const seg of segments) {
|
||||
if (
|
||||
!startAnchor &&
|
||||
matchStart >= seg.start &&
|
||||
matchStart <= seg.end
|
||||
) {
|
||||
startAnchor = { node: seg.node, offset: matchStart - seg.start };
|
||||
}
|
||||
if (matchEnd >= seg.start && matchEnd <= seg.end) {
|
||||
endAnchor = { node: seg.node, offset: matchEnd - seg.start };
|
||||
}
|
||||
if (startAnchor && endAnchor) break;
|
||||
}
|
||||
if (!startAnchor || !endAnchor) return null;
|
||||
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.setStart(startAnchor.node, startAnchor.offset);
|
||||
range.setEnd(endAnchor.node, endAnchor.offset);
|
||||
return range;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and highlight text in the DOM that matches a decoded text fragment.
|
||||
* Returns true if found and highlighted.
|
||||
@@ -185,17 +605,22 @@ export function findAndHighlightText(fragment: {
|
||||
let normText = "";
|
||||
const posMap: number[] = []; // posMap[normIdx] = rawIdx
|
||||
let prevParent: Element | null = null;
|
||||
let prevBlock: Element | null = null;
|
||||
|
||||
for (const node of allTextNodes) {
|
||||
const text = node.textContent || "";
|
||||
const parent = node.parentElement;
|
||||
const block = nearestBlockAncestor(parent);
|
||||
|
||||
// Insert a space in normText when crossing element boundaries
|
||||
// (so cross-paragraph searches work), but NOT in rawText
|
||||
// (so segment offsets stay accurate for DOM highlighting).
|
||||
// Insert a space in normText only when crossing a *block-level*
|
||||
// boundary (so cross-paragraph searches still work), but NOT when
|
||||
// crossing inline boundaries like <i>, <em>, <span> — otherwise a
|
||||
// selection like "(Madhya-līlā) it" gets reconstructed as
|
||||
// "( Madhya-līlā ) it" and the prefix match fails. rawText stays
|
||||
// exact so DOM offsets remain accurate for highlighting.
|
||||
if (
|
||||
prevParent &&
|
||||
parent !== prevParent &&
|
||||
prevBlock !== block &&
|
||||
normText.length > 0 &&
|
||||
!normText.endsWith(" ") &&
|
||||
!text.startsWith(" ")
|
||||
@@ -204,6 +629,7 @@ export function findAndHighlightText(fragment: {
|
||||
normText += " ";
|
||||
}
|
||||
prevParent = parent;
|
||||
prevBlock = block;
|
||||
|
||||
const segStart = rawText.length;
|
||||
segments.push({
|
||||
@@ -239,10 +665,16 @@ export function findAndHighlightText(fragment: {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedFullLower = collapsedNorm.toLowerCase();
|
||||
// Normalize for matching so prefix/suffix and DOM text agree on quote style
|
||||
// and diacritics (search snippets come from the `_ascii` shadow field, the
|
||||
// DOM renders the original IAST form). Length-preserving for NFC input, so
|
||||
// collapsedPosMap indices remain valid against rawText.
|
||||
const normalizedFullLower = normalizeForMatch(collapsedNorm);
|
||||
|
||||
// Search for the prefix in the full text (case-insensitive)
|
||||
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase();
|
||||
const normalizedPrefix = normalizeForMatch(
|
||||
fragment.prefix.replace(/\s+/g, " "),
|
||||
);
|
||||
let searchFrom = 0;
|
||||
let matchStartNorm = -1;
|
||||
|
||||
@@ -252,9 +684,9 @@ export function findAndHighlightText(fragment: {
|
||||
|
||||
// If we have a suffix, verify it appears near the expected length
|
||||
if (fragment.suffix) {
|
||||
const normalizedSuffix = fragment.suffix
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
const normalizedSuffix = normalizeForMatch(
|
||||
fragment.suffix.replace(/\s+/g, " "),
|
||||
);
|
||||
// Search for suffix near the expected end position in the normalized string.
|
||||
// Use a wider tolerance window (±50) since normalization can shift positions.
|
||||
const expectedEndNorm = idx + fragment.length;
|
||||
@@ -289,12 +721,35 @@ export function findAndHighlightText(fragment: {
|
||||
collapsedNorm.length,
|
||||
);
|
||||
|
||||
const matchStart = collapsedPosMap[matchStartNorm] ?? 0;
|
||||
const matchEnd =
|
||||
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
|
||||
let matchEnd =
|
||||
matchEndNorm < collapsedPosMap.length
|
||||
? collapsedPosMap[matchEndNorm]
|
||||
: rawText.length;
|
||||
|
||||
// Defensive raw-text alignment — same logic as markFragmentAsBookmark.
|
||||
// Prefer the raw-text index when the prefix is plain enough to match
|
||||
// directly, since collapsedPosMap can drift on certain inputs.
|
||||
//
|
||||
// The matchEnd choice depends on the fragment shape (see encoder):
|
||||
// • prefix-only (no suffix) → the prefix IS the full selection, so
|
||||
// matchEnd = directIdx + prefix.length.
|
||||
// • prefix + suffix → the selection spans from prefix-start to
|
||||
// suffix-end, and `fragment.length` is that full span. We must use
|
||||
// it; collapsing to just prefix.length here was the bug that made
|
||||
// long highlights show only the first 4 words.
|
||||
const rawLower = rawText.toLowerCase();
|
||||
const prefixLower = fragment.prefix.toLowerCase();
|
||||
const directIdx = rawLower.indexOf(prefixLower);
|
||||
if (directIdx >= 0) {
|
||||
matchStart = directIdx;
|
||||
const endByLength =
|
||||
fragment.suffix && fragment.length > fragment.prefix.length
|
||||
? directIdx + fragment.length
|
||||
: directIdx + fragment.prefix.length;
|
||||
matchEnd = Math.min(endByLength, rawText.length);
|
||||
}
|
||||
|
||||
// Map the match range back to DOM text nodes and wrap with <mark>
|
||||
for (const seg of segments) {
|
||||
const overlapStart = Math.max(matchStart, seg.start);
|
||||
@@ -339,3 +794,232 @@ export function findAndHighlightText(fragment: {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistent bookmark marks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrap the bookmarked passage in a `<mark data-bookmark-id="X">` with a
|
||||
* violet wash so the user sees their saved passage every time they visit
|
||||
* the page, until they remove the bookmark.
|
||||
*
|
||||
* Distinct from `findAndHighlightText` in three ways:
|
||||
* • Persistent — does NOT scroll into view (only the floating icon
|
||||
* handles that affordance) and uses a different colour from `?hl=`
|
||||
* (amber) so transient share-link landings stay visually distinct.
|
||||
* • Idempotent — if a mark for this bookmark id already exists, we
|
||||
* return the existing nodes without re-wrapping.
|
||||
* • Returns the created (or existing) `<mark>` elements so the caller
|
||||
* can measure their bounding rect to anchor a floating indicator.
|
||||
*
|
||||
* The matching pipeline mirrors `findAndHighlightText`; we intentionally
|
||||
* inline the duplicated logic for now (clear-and-obvious beats clever
|
||||
* abstraction while we iterate on this feature).
|
||||
*/
|
||||
export function markFragmentAsBookmark(
|
||||
fragment: { prefix: string; suffix: string; length: number },
|
||||
bookmarkId: string,
|
||||
): HTMLElement[] {
|
||||
// Idempotency — bail if marks for this bookmark are already in the DOM.
|
||||
const existing = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
`[data-bookmark-id="${CSS.escape(bookmarkId)}"]`,
|
||||
),
|
||||
);
|
||||
if (existing.length > 0) return existing;
|
||||
|
||||
const contentAreas = document.querySelectorAll(
|
||||
"main, [data-slot=card-content], article, [data-content-area]",
|
||||
);
|
||||
if (contentAreas.length === 0) return [];
|
||||
|
||||
const allTextNodes: Text[] = [];
|
||||
contentAreas.forEach((area) => {
|
||||
const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
if (node.textContent && node.textContent.trim().length > 0) {
|
||||
allTextNodes.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Same segment/normalisation pipeline as findAndHighlightText; see the
|
||||
// BLOCK_TAGS comment above for the inline-vs-block boundary rule.
|
||||
const segments: { node: Text; start: number; end: number }[] = [];
|
||||
let rawText = "";
|
||||
let normText = "";
|
||||
const posMap: number[] = [];
|
||||
let prevParent: Element | null = null;
|
||||
let prevBlock: Element | null = null;
|
||||
for (const node of allTextNodes) {
|
||||
const text = node.textContent || "";
|
||||
const parent = node.parentElement;
|
||||
const block = nearestBlockAncestor(parent);
|
||||
if (
|
||||
prevParent &&
|
||||
prevBlock !== block &&
|
||||
normText.length > 0 &&
|
||||
!normText.endsWith(" ") &&
|
||||
!text.startsWith(" ")
|
||||
) {
|
||||
posMap.push(rawText.length);
|
||||
normText += " ";
|
||||
}
|
||||
prevParent = parent;
|
||||
prevBlock = block;
|
||||
const segStart = rawText.length;
|
||||
segments.push({ node, start: segStart, end: segStart + text.length });
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
posMap.push(rawText.length + i);
|
||||
normText += text[i];
|
||||
}
|
||||
rawText += text;
|
||||
}
|
||||
let collapsedNorm = "";
|
||||
const collapsedPosMap: number[] = [];
|
||||
let lastWasSpace = false;
|
||||
for (let i = 0; i < normText.length; i++) {
|
||||
const ch = normText[i];
|
||||
const isSpace = /\s/.test(ch);
|
||||
if (isSpace) {
|
||||
if (!lastWasSpace) {
|
||||
collapsedPosMap.push(posMap[i]);
|
||||
collapsedNorm += " ";
|
||||
}
|
||||
lastWasSpace = true;
|
||||
} else {
|
||||
collapsedPosMap.push(posMap[i]);
|
||||
collapsedNorm += ch;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
}
|
||||
const normalizedFullLower = normalizeForMatch(collapsedNorm);
|
||||
const normalizedPrefix = normalizeForMatch(
|
||||
fragment.prefix.replace(/\s+/g, " "),
|
||||
);
|
||||
let searchFrom = 0;
|
||||
let matchStartNorm = -1;
|
||||
while (searchFrom < normalizedFullLower.length) {
|
||||
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
|
||||
if (idx === -1) break;
|
||||
if (fragment.suffix) {
|
||||
const normalizedSuffix = normalizeForMatch(
|
||||
fragment.suffix.replace(/\s+/g, " "),
|
||||
);
|
||||
const expectedEndNorm = idx + fragment.length;
|
||||
const suffixSearchStart = Math.max(
|
||||
idx,
|
||||
expectedEndNorm - normalizedSuffix.length - 50,
|
||||
);
|
||||
const suffixSearchEnd = Math.min(
|
||||
normalizedFullLower.length,
|
||||
expectedEndNorm + 50,
|
||||
);
|
||||
const searchWindow = normalizedFullLower.slice(
|
||||
suffixSearchStart,
|
||||
suffixSearchEnd,
|
||||
);
|
||||
if (searchWindow.includes(normalizedSuffix)) {
|
||||
matchStartNorm = idx;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
matchStartNorm = idx;
|
||||
break;
|
||||
}
|
||||
searchFrom = idx + 1;
|
||||
}
|
||||
if (matchStartNorm === -1) return [];
|
||||
|
||||
const matchEndNorm = Math.min(
|
||||
matchStartNorm + fragment.length,
|
||||
collapsedNorm.length,
|
||||
);
|
||||
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
|
||||
let matchEnd =
|
||||
matchEndNorm < collapsedPosMap.length
|
||||
? collapsedPosMap[matchEndNorm]
|
||||
: rawText.length;
|
||||
|
||||
// Defensive: cross-check the position against a direct case-insensitive
|
||||
// search in rawText. If the prefix is plain enough to find directly,
|
||||
// prefer the raw-text index — collapsedPosMap can drift by one when the
|
||||
// normalization pipeline produces different code-unit counts than the
|
||||
// original (NFD decomposition, Unicode lowercase widening, etc.), and
|
||||
// that one-character shift is what dropped the leading character of
|
||||
// matches in the wild.
|
||||
//
|
||||
// For range fragments (prefix + suffix), `fragment.length` is the full
|
||||
// span — using prefix.length here would shrink the highlight to just
|
||||
// the first few words.
|
||||
const rawLower = rawText.toLowerCase();
|
||||
const prefixLower = fragment.prefix.toLowerCase();
|
||||
const directIdx = rawLower.indexOf(prefixLower);
|
||||
if (directIdx >= 0) {
|
||||
matchStart = directIdx;
|
||||
const endByLength =
|
||||
fragment.suffix && fragment.length > fragment.prefix.length
|
||||
? directIdx + fragment.length
|
||||
: directIdx + fragment.prefix.length;
|
||||
matchEnd = Math.min(endByLength, rawText.length);
|
||||
}
|
||||
|
||||
// Tailwind classes — violet wash + slight rounded corners. Picked to be
|
||||
// visually distinct from the amber used by ?hl= so a user landing on a
|
||||
// share-link sees the transient amber on TOP of any persistent violet
|
||||
// bookmark mark (two colours = clearly two concepts).
|
||||
const MARK_CLASS =
|
||||
"bg-violet-300/45 dark:bg-violet-400/30 text-foreground rounded-sm px-0.5";
|
||||
|
||||
const created: HTMLElement[] = [];
|
||||
for (const seg of segments) {
|
||||
const overlapStart = Math.max(matchStart, seg.start);
|
||||
const overlapEnd = Math.min(matchEnd, seg.end);
|
||||
if (overlapStart >= overlapEnd) continue;
|
||||
|
||||
const node = seg.node;
|
||||
const text = node.textContent || "";
|
||||
const localStart = overlapStart - seg.start;
|
||||
const localEnd = overlapEnd - seg.start;
|
||||
const before = text.slice(0, localStart);
|
||||
const matched = text.slice(localStart, localEnd);
|
||||
const after = text.slice(localEnd);
|
||||
const parent = node.parentNode;
|
||||
if (!parent) continue;
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
if (before) frag.appendChild(document.createTextNode(before));
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = MARK_CLASS;
|
||||
mark.setAttribute("data-bookmark-id", bookmarkId);
|
||||
mark.textContent = matched;
|
||||
frag.appendChild(mark);
|
||||
if (after) frag.appendChild(document.createTextNode(after));
|
||||
parent.replaceChild(frag, node);
|
||||
created.push(mark);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap bookmark marks. Pass an id to remove a single bookmark's marks;
|
||||
* pass nothing to remove every bookmark mark in the document (used when
|
||||
* the PageBookmarks overlay unmounts on route change).
|
||||
*/
|
||||
export function removeBookmarkMarks(bookmarkId?: string): void {
|
||||
const selector = bookmarkId
|
||||
? `[data-bookmark-id="${CSS.escape(bookmarkId)}"]`
|
||||
: `[data-bookmark-id]`;
|
||||
document.querySelectorAll<HTMLElement>(selector).forEach((mark) => {
|
||||
const parent = mark.parentNode;
|
||||
if (!parent) return;
|
||||
parent.replaceChild(
|
||||
document.createTextNode(mark.textContent ?? ""),
|
||||
mark,
|
||||
);
|
||||
// Merge adjacent text nodes so subsequent matchers see a clean tree.
|
||||
parent.normalize();
|
||||
});
|
||||
}
|
||||
|
||||
Generated
+9103
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz"
|
||||
integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==
|
||||
|
||||
"@babel/core@^7.24.4", "@babel/core@^7.28.0":
|
||||
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.24.4", "@babel/core@^7.28.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz"
|
||||
integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==
|
||||
@@ -273,6 +273,11 @@
|
||||
reselect "^5.1.1"
|
||||
use-sync-external-store "^1.6.0"
|
||||
|
||||
"@date-fns/tz@^1.2.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz"
|
||||
integrity sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==
|
||||
|
||||
"@dotenvx/dotenvx@^1.48.4":
|
||||
version "1.61.0"
|
||||
resolved "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz"
|
||||
@@ -294,158 +299,11 @@
|
||||
resolved "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz"
|
||||
integrity sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==
|
||||
|
||||
"@emnapi/core@^1.4.3", "@emnapi/core@^1.8.1":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
|
||||
integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==
|
||||
dependencies:
|
||||
"@emnapi/wasi-threads" "1.2.1"
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0", "@emnapi/runtime@^1.8.1":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
|
||||
integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.1.0":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
|
||||
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@esbuild/aix-ppc64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53"
|
||||
integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==
|
||||
|
||||
"@esbuild/android-arm64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d"
|
||||
integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==
|
||||
|
||||
"@esbuild/android-arm@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d"
|
||||
integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==
|
||||
|
||||
"@esbuild/android-x64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07"
|
||||
integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==
|
||||
|
||||
"@esbuild/darwin-arm64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz"
|
||||
integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==
|
||||
|
||||
"@esbuild/darwin-x64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be"
|
||||
integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==
|
||||
|
||||
"@esbuild/freebsd-arm64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62"
|
||||
integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==
|
||||
|
||||
"@esbuild/freebsd-x64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6"
|
||||
integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==
|
||||
|
||||
"@esbuild/linux-arm64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966"
|
||||
integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==
|
||||
|
||||
"@esbuild/linux-arm@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921"
|
||||
integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==
|
||||
|
||||
"@esbuild/linux-ia32@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e"
|
||||
integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==
|
||||
|
||||
"@esbuild/linux-loong64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205"
|
||||
integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==
|
||||
|
||||
"@esbuild/linux-mips64el@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8"
|
||||
integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==
|
||||
|
||||
"@esbuild/linux-ppc64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea"
|
||||
integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==
|
||||
|
||||
"@esbuild/linux-riscv64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027"
|
||||
integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==
|
||||
|
||||
"@esbuild/linux-s390x@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6"
|
||||
integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==
|
||||
|
||||
"@esbuild/linux-x64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a"
|
||||
integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==
|
||||
|
||||
"@esbuild/netbsd-arm64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690"
|
||||
integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==
|
||||
|
||||
"@esbuild/netbsd-x64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320"
|
||||
integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==
|
||||
|
||||
"@esbuild/openbsd-arm64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1"
|
||||
integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==
|
||||
|
||||
"@esbuild/openbsd-x64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179"
|
||||
integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==
|
||||
|
||||
"@esbuild/openharmony-arm64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410"
|
||||
integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==
|
||||
|
||||
"@esbuild/sunos-x64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d"
|
||||
integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==
|
||||
|
||||
"@esbuild/win32-arm64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77"
|
||||
integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==
|
||||
|
||||
"@esbuild/win32-ia32@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d"
|
||||
integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==
|
||||
|
||||
"@esbuild/win32-x64@0.27.7":
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b"
|
||||
integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==
|
||||
|
||||
"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
|
||||
version "4.9.1"
|
||||
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz"
|
||||
@@ -581,141 +439,11 @@
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-darwin-arm64" "1.2.4"
|
||||
|
||||
"@img/sharp-darwin-x64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b"
|
||||
integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-darwin-x64" "1.2.4"
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz"
|
||||
integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==
|
||||
|
||||
"@img/sharp-libvips-darwin-x64@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc"
|
||||
integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==
|
||||
|
||||
"@img/sharp-libvips-linux-arm64@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318"
|
||||
integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==
|
||||
|
||||
"@img/sharp-libvips-linux-arm@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d"
|
||||
integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7"
|
||||
integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de"
|
||||
integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==
|
||||
|
||||
"@img/sharp-libvips-linux-s390x@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec"
|
||||
integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==
|
||||
|
||||
"@img/sharp-libvips-linux-x64@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce"
|
||||
integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06"
|
||||
integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75"
|
||||
integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==
|
||||
|
||||
"@img/sharp-linux-arm64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc"
|
||||
integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-arm64" "1.2.4"
|
||||
|
||||
"@img/sharp-linux-arm@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d"
|
||||
integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-arm" "1.2.4"
|
||||
|
||||
"@img/sharp-linux-ppc64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813"
|
||||
integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-ppc64" "1.2.4"
|
||||
|
||||
"@img/sharp-linux-riscv64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60"
|
||||
integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-riscv64" "1.2.4"
|
||||
|
||||
"@img/sharp-linux-s390x@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7"
|
||||
integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-s390x" "1.2.4"
|
||||
|
||||
"@img/sharp-linux-x64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8"
|
||||
integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linux-x64" "1.2.4"
|
||||
|
||||
"@img/sharp-linuxmusl-arm64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086"
|
||||
integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linuxmusl-arm64" "1.2.4"
|
||||
|
||||
"@img/sharp-linuxmusl-x64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f"
|
||||
integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==
|
||||
optionalDependencies:
|
||||
"@img/sharp-libvips-linuxmusl-x64" "1.2.4"
|
||||
|
||||
"@img/sharp-wasm32@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0"
|
||||
integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==
|
||||
dependencies:
|
||||
"@emnapi/runtime" "^1.7.0"
|
||||
|
||||
"@img/sharp-win32-arm64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a"
|
||||
integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==
|
||||
|
||||
"@img/sharp-win32-ia32@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de"
|
||||
integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==
|
||||
|
||||
"@img/sharp-win32-x64@0.34.5":
|
||||
version "0.34.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8"
|
||||
integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==
|
||||
|
||||
"@inquirer/ansi@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz"
|
||||
@@ -822,22 +550,6 @@
|
||||
outvariant "^1.4.3"
|
||||
strict-event-emitter "^0.5.1"
|
||||
|
||||
"@napi-rs/wasm-runtime@^0.2.11":
|
||||
version "0.2.12"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2"
|
||||
integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==
|
||||
dependencies:
|
||||
"@emnapi/core" "^1.4.3"
|
||||
"@emnapi/runtime" "^1.4.3"
|
||||
"@tybys/wasm-util" "^0.10.0"
|
||||
|
||||
"@napi-rs/wasm-runtime@^1.1.1":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"
|
||||
integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==
|
||||
dependencies:
|
||||
"@tybys/wasm-util" "^0.10.1"
|
||||
|
||||
"@next/env@16.2.2":
|
||||
version "16.2.2"
|
||||
resolved "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz"
|
||||
@@ -855,42 +567,7 @@
|
||||
resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz"
|
||||
integrity sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==
|
||||
|
||||
"@next/swc-darwin-x64@16.2.2":
|
||||
version "16.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz#729584bfae41fca5e381229a3d1fe0eaf313cc0e"
|
||||
integrity sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==
|
||||
|
||||
"@next/swc-linux-arm64-gnu@16.2.2":
|
||||
version "16.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz#edd526da879fe56e4cb82d57aeb5174956c65493"
|
||||
integrity sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==
|
||||
|
||||
"@next/swc-linux-arm64-musl@16.2.2":
|
||||
version "16.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz#f5990229520663cd759b0eaa426dace2b0510a10"
|
||||
integrity sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==
|
||||
|
||||
"@next/swc-linux-x64-gnu@16.2.2":
|
||||
version "16.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz#49b3fcdf73e0403fde8dc9309488c5dd3d19b587"
|
||||
integrity sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==
|
||||
|
||||
"@next/swc-linux-x64-musl@16.2.2":
|
||||
version "16.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz#c7fa2e5cb97876efcc8773ae892e426aec1b6f97"
|
||||
integrity sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==
|
||||
|
||||
"@next/swc-win32-arm64-msvc@16.2.2":
|
||||
version "16.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz#4d144d95344d684b62710246b15f306b3ee24341"
|
||||
integrity sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==
|
||||
|
||||
"@next/swc-win32-x64-msvc@16.2.2":
|
||||
version "16.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz#1120617f423b81a4ba588b289aeafc9c03526b71"
|
||||
integrity sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==
|
||||
|
||||
"@noble/ciphers@^1.3.0":
|
||||
"@noble/ciphers@^1.0.0", "@noble/ciphers@^1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz"
|
||||
integrity sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==
|
||||
@@ -902,7 +579,7 @@
|
||||
dependencies:
|
||||
"@noble/hashes" "1.8.0"
|
||||
|
||||
"@noble/hashes@1.8.0", "@noble/hashes@^1.8.0":
|
||||
"@noble/hashes@^1.8.0", "@noble/hashes@1.8.0":
|
||||
version "1.8.0"
|
||||
resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz"
|
||||
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
|
||||
@@ -915,7 +592,7 @@
|
||||
"@nodelib/fs.stat" "2.0.5"
|
||||
run-parallel "^1.1.9"
|
||||
|
||||
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
|
||||
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
|
||||
version "2.0.5"
|
||||
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
||||
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||
@@ -1115,73 +792,11 @@
|
||||
source-map-js "^1.2.1"
|
||||
tailwindcss "4.2.2"
|
||||
|
||||
"@tailwindcss/oxide-android-arm64@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz#61d9ec5c18394fe7a972e99e19e6065e833da77c"
|
||||
integrity sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz"
|
||||
integrity sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz#a5899f1fbe55c4eddcbc871b835d5183ba34658c"
|
||||
integrity sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz#76185bb1bea9af915a5b9f465323861646587e21"
|
||||
integrity sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz#74c17c69b2015f7600d566ab0990aaac8701128e"
|
||||
integrity sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz#38a846d9d5795bc3b57951172044d8dbb3c79aa6"
|
||||
integrity sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz#f4cc4129c17d3f2bcb01efef4d7a2f381e5e3f53"
|
||||
integrity sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz#7c4a00b0829e12736bd72ec74e1c08205448cc2e"
|
||||
integrity sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz#711756d7bbe97e221fc041b63a4f385b85ba4321"
|
||||
integrity sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz#ed6d28567b7abb8505f824457c236d2cd07ee18e"
|
||||
integrity sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==
|
||||
dependencies:
|
||||
"@emnapi/core" "^1.8.1"
|
||||
"@emnapi/runtime" "^1.8.1"
|
||||
"@emnapi/wasi-threads" "^1.1.0"
|
||||
"@napi-rs/wasm-runtime" "^1.1.1"
|
||||
"@tybys/wasm-util" "^0.10.1"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz#f2d0360e5bc06fe201537fb08193d3780e7dd24f"
|
||||
integrity sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz#10fc71b73883f9c3999b5b8c338fd96a45240dcb"
|
||||
integrity sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==
|
||||
|
||||
"@tailwindcss/oxide@4.2.2":
|
||||
version "4.2.2"
|
||||
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz"
|
||||
@@ -1220,13 +835,6 @@
|
||||
minimatch "^10.0.1"
|
||||
path-browserify "^1.0.1"
|
||||
|
||||
"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1":
|
||||
version "0.10.2"
|
||||
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e"
|
||||
integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@types/estree@^1.0.6":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
|
||||
@@ -1242,19 +850,19 @@
|
||||
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
|
||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||
|
||||
"@types/node@^20":
|
||||
"@types/node@^20", "@types/node@>=18":
|
||||
version "20.19.38"
|
||||
resolved "https://registry.npmjs.org/@types/node/-/node-20.19.38.tgz"
|
||||
integrity sha512-grSwveyouVpXDwUvnpIb5noOpZQGOzbVdZXdjw8P9WOjnrUenKj2YuIh35OpXQ+UCmMQEgyvRobT5uuK9iDCUQ==
|
||||
dependencies:
|
||||
undici-types "~6.21.0"
|
||||
|
||||
"@types/react-dom@^19":
|
||||
"@types/react-dom@*", "@types/react-dom@^19":
|
||||
version "19.2.3"
|
||||
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz"
|
||||
integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==
|
||||
|
||||
"@types/react@^19":
|
||||
"@types/react@*", "@types/react@^17 || ^18 || ^19", "@types/react@^19", "@types/react@^19.2.0":
|
||||
version "19.2.14"
|
||||
resolved "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz"
|
||||
integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==
|
||||
@@ -1285,7 +893,7 @@
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.58.0":
|
||||
"@typescript-eslint/parser@^8.58.0", "@typescript-eslint/parser@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz"
|
||||
integrity sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==
|
||||
@@ -1313,7 +921,7 @@
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.58.0", "@typescript-eslint/tsconfig-utils@^8.58.0":
|
||||
"@typescript-eslint/tsconfig-utils@^8.58.0", "@typescript-eslint/tsconfig-utils@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz"
|
||||
integrity sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==
|
||||
@@ -1329,7 +937,7 @@
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.58.0", "@typescript-eslint/types@^8.58.0":
|
||||
"@typescript-eslint/types@^8.58.0", "@typescript-eslint/types@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz"
|
||||
integrity sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==
|
||||
@@ -1367,103 +975,11 @@
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81"
|
||||
integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==
|
||||
|
||||
"@unrs/resolver-binding-android-arm64@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f"
|
||||
integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==
|
||||
|
||||
"@unrs/resolver-binding-darwin-arm64@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz"
|
||||
integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==
|
||||
|
||||
"@unrs/resolver-binding-darwin-x64@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc"
|
||||
integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==
|
||||
|
||||
"@unrs/resolver-binding-freebsd-x64@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b"
|
||||
integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==
|
||||
|
||||
"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a"
|
||||
integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==
|
||||
|
||||
"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3"
|
||||
integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==
|
||||
|
||||
"@unrs/resolver-binding-linux-arm64-gnu@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d"
|
||||
integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==
|
||||
|
||||
"@unrs/resolver-binding-linux-arm64-musl@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0"
|
||||
integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==
|
||||
|
||||
"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44"
|
||||
integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==
|
||||
|
||||
"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9"
|
||||
integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==
|
||||
|
||||
"@unrs/resolver-binding-linux-riscv64-musl@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165"
|
||||
integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==
|
||||
|
||||
"@unrs/resolver-binding-linux-s390x-gnu@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94"
|
||||
integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==
|
||||
|
||||
"@unrs/resolver-binding-linux-x64-gnu@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935"
|
||||
integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==
|
||||
|
||||
"@unrs/resolver-binding-linux-x64-musl@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6"
|
||||
integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d"
|
||||
integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==
|
||||
dependencies:
|
||||
"@napi-rs/wasm-runtime" "^0.2.11"
|
||||
|
||||
"@unrs/resolver-binding-win32-arm64-msvc@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35"
|
||||
integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==
|
||||
|
||||
"@unrs/resolver-binding-win32-ia32-msvc@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6"
|
||||
integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
|
||||
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
|
||||
|
||||
accepts@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz"
|
||||
@@ -1477,7 +993,7 @@ acorn-jsx@^5.3.2:
|
||||
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
acorn@^8.15.0:
|
||||
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0:
|
||||
version "8.16.0"
|
||||
resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz"
|
||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||
@@ -1725,7 +1241,7 @@ braces@^3.0.3:
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
|
||||
browserslist@^4.24.0, browserslist@^4.26.2:
|
||||
browserslist@^4.24.0, browserslist@^4.26.2, "browserslist@>= 4.21.0":
|
||||
version "4.28.2"
|
||||
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz"
|
||||
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
|
||||
@@ -1976,12 +1492,10 @@ data-view-byte-offset@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
is-data-view "^1.0.1"
|
||||
|
||||
debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
date-fns@^4.0.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz"
|
||||
integrity sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==
|
||||
|
||||
debug@^3.2.7:
|
||||
version "3.2.7"
|
||||
@@ -1990,6 +1504,13 @@ debug@^3.2.7:
|
||||
dependencies:
|
||||
ms "^2.1.1"
|
||||
|
||||
debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3, debug@4:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
dedent@^1.6.0:
|
||||
version "1.7.2"
|
||||
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz"
|
||||
@@ -2359,7 +1880,7 @@ eslint-module-utils@^2.12.1:
|
||||
dependencies:
|
||||
debug "^3.2.7"
|
||||
|
||||
eslint-plugin-import@^2.32.0:
|
||||
eslint-plugin-import@*, eslint-plugin-import@^2.32.0:
|
||||
version "2.32.0"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz"
|
||||
integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==
|
||||
@@ -2463,7 +1984,7 @@ eslint-visitor-keys@^5.0.0:
|
||||
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz"
|
||||
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||
|
||||
eslint@^9:
|
||||
eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7", "eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0 || ^10.0.0", eslint@^9, eslint@>=9.0.0:
|
||||
version "9.39.4"
|
||||
resolved "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz"
|
||||
integrity sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==
|
||||
@@ -2598,7 +2119,7 @@ express-rate-limit@^8.2.1:
|
||||
dependencies:
|
||||
ip-address "10.1.0"
|
||||
|
||||
express@^5.2.1:
|
||||
express@^5.2.1, "express@>= 4.11":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.npmjs.org/express/-/express-5.2.1.tgz"
|
||||
integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==
|
||||
@@ -2637,17 +2158,6 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-glob@3.3.1:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz"
|
||||
integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
|
||||
dependencies:
|
||||
"@nodelib/fs.stat" "^2.0.2"
|
||||
"@nodelib/fs.walk" "^1.2.3"
|
||||
glob-parent "^5.1.2"
|
||||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.4"
|
||||
|
||||
fast-glob@^3.3.3:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz"
|
||||
@@ -2659,6 +2169,17 @@ fast-glob@^3.3.3:
|
||||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.8"
|
||||
|
||||
fast-glob@3.3.1:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz"
|
||||
integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
|
||||
dependencies:
|
||||
"@nodelib/fs.stat" "^2.0.2"
|
||||
"@nodelib/fs.walk" "^1.2.3"
|
||||
glob-parent "^5.1.2"
|
||||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.4"
|
||||
|
||||
fast-json-stable-stringify@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
|
||||
@@ -2769,7 +2290,7 @@ forwarded@0.2.0:
|
||||
|
||||
framer-motion@^12.38.0:
|
||||
version "12.38.0"
|
||||
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.38.0.tgz#cf28e072a95942881ca4e33fd33be41192fd146b"
|
||||
resolved "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz"
|
||||
integrity sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==
|
||||
dependencies:
|
||||
motion-dom "^12.38.0"
|
||||
@@ -2919,16 +2440,16 @@ glob-parent@^6.0.2:
|
||||
dependencies:
|
||||
is-glob "^4.0.3"
|
||||
|
||||
globals@16.4.0:
|
||||
version "16.4.0"
|
||||
resolved "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz"
|
||||
integrity sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==
|
||||
|
||||
globals@^14.0.0:
|
||||
version "14.0.0"
|
||||
resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
|
||||
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
|
||||
|
||||
globals@16.4.0:
|
||||
version "16.4.0"
|
||||
resolved "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz"
|
||||
integrity sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==
|
||||
|
||||
globalthis@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz"
|
||||
@@ -3012,7 +2533,7 @@ hermes-parser@^0.25.1:
|
||||
dependencies:
|
||||
hermes-estree "0.25.1"
|
||||
|
||||
hono@^4.11.4:
|
||||
hono@^4, hono@^4.11.4:
|
||||
version "4.12.12"
|
||||
resolved "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz"
|
||||
integrity sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==
|
||||
@@ -3401,7 +2922,7 @@ iterator.prototype@^1.1.5:
|
||||
has-symbols "^1.1.0"
|
||||
set-function-name "^2.0.2"
|
||||
|
||||
jiti@^2.6.1:
|
||||
jiti@*, jiti@^2.6.1:
|
||||
version "2.6.1"
|
||||
resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"
|
||||
integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
|
||||
@@ -3526,61 +3047,11 @@ levn@^0.4.1:
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "~0.4.0"
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
|
||||
integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
|
||||
|
||||
lightningcss-darwin-arm64@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz"
|
||||
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
|
||||
|
||||
lightningcss-darwin-x64@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
|
||||
integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
|
||||
|
||||
lightningcss-freebsd-x64@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
|
||||
integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
|
||||
integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
|
||||
integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
|
||||
integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
|
||||
integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
|
||||
integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
|
||||
integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
|
||||
|
||||
lightningcss-win32-x64-msvc@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
|
||||
integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
|
||||
|
||||
lightningcss@1.32.0:
|
||||
version "1.32.0"
|
||||
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz"
|
||||
@@ -3706,7 +3177,14 @@ mimic-function@^5.0.0:
|
||||
resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz"
|
||||
integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==
|
||||
|
||||
minimatch@^10.0.1, minimatch@^10.2.2:
|
||||
minimatch@^10.0.1:
|
||||
version "10.2.5"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz"
|
||||
integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
|
||||
dependencies:
|
||||
brace-expansion "^5.0.5"
|
||||
|
||||
minimatch@^10.2.2:
|
||||
version "10.2.5"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz"
|
||||
integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
|
||||
@@ -3727,14 +3205,14 @@ minimist@^1.2.0, minimist@^1.2.6:
|
||||
|
||||
motion-dom@^12.38.0:
|
||||
version "12.38.0"
|
||||
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.38.0.tgz#9ef3253ea0fb28b6757588327073848d940e9aab"
|
||||
resolved "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz"
|
||||
integrity sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==
|
||||
dependencies:
|
||||
motion-utils "^12.36.0"
|
||||
|
||||
motion-utils@^12.36.0:
|
||||
version "12.36.0"
|
||||
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.36.0.tgz#cff2df2a28c3fe53a3de7e0103ba7f73ff7d77a7"
|
||||
resolved "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz"
|
||||
integrity sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==
|
||||
|
||||
ms@^2.1.1, ms@^2.1.3:
|
||||
@@ -4100,7 +3578,7 @@ picomatch@^2.3.1:
|
||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz"
|
||||
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
||||
|
||||
picomatch@^4.0.2, picomatch@^4.0.3:
|
||||
"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz"
|
||||
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
||||
@@ -4123,15 +3601,6 @@ postcss-selector-parser@^7.1.0:
|
||||
cssesc "^3.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
postcss@8.4.31:
|
||||
version "8.4.31"
|
||||
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz"
|
||||
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
|
||||
dependencies:
|
||||
nanoid "^3.3.6"
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
postcss@^8.5.6:
|
||||
version "8.5.8"
|
||||
resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz"
|
||||
@@ -4141,6 +3610,15 @@ postcss@^8.5.6:
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
postcss@8.4.31:
|
||||
version "8.4.31"
|
||||
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz"
|
||||
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
|
||||
dependencies:
|
||||
nanoid "^3.3.6"
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
powershell-utils@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz"
|
||||
@@ -4215,7 +3693,7 @@ raw-body@^3.0.0, raw-body@^3.0.1:
|
||||
iconv-lite "~0.7.0"
|
||||
unpipe "~1.0.0"
|
||||
|
||||
react-dom@19.2.4:
|
||||
"react-dom@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom@^17 || ^18 || ^19", "react-dom@^18.0.0 || ^19.0.0", "react-dom@^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react-dom@>=16.8.0, react-dom@19.2.4:
|
||||
version "19.2.4"
|
||||
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz"
|
||||
integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==
|
||||
@@ -4254,7 +3732,7 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
||||
get-nonce "^1.0.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
react@19.2.4:
|
||||
"react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^17 || ^18 || ^19", "react@^18.0.0 || ^19.0.0", "react@^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react@^19.2.4, "react@>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0", react@>=16.8.0, react@19.2.4:
|
||||
version "19.2.4"
|
||||
resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz"
|
||||
integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==
|
||||
@@ -4417,7 +3895,12 @@ semver@^6.3.1:
|
||||
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.7.1, semver@^7.7.3:
|
||||
semver@^7.7.1:
|
||||
version "7.7.4"
|
||||
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
|
||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
||||
|
||||
semver@^7.7.3:
|
||||
version "7.7.4"
|
||||
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
|
||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
||||
@@ -4626,6 +4109,11 @@ sisteransi@^1.0.5:
|
||||
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
|
||||
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
|
||||
|
||||
sonner@^2.0.7:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz"
|
||||
integrity sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==
|
||||
|
||||
source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
||||
@@ -4822,7 +4310,7 @@ tailwind-merge@^3.0.1, tailwind-merge@^3.5.0:
|
||||
resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz"
|
||||
integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==
|
||||
|
||||
tailwindcss@4.2.2, tailwindcss@^4:
|
||||
tailwindcss@^4, tailwindcss@4.2.2:
|
||||
version "4.2.2"
|
||||
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz"
|
||||
integrity sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==
|
||||
@@ -4908,7 +4396,7 @@ tsconfig-paths@^4.2.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1:
|
||||
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
@@ -5006,7 +4494,7 @@ typescript-eslint@^8.46.0:
|
||||
"@typescript-eslint/typescript-estree" "8.58.0"
|
||||
"@typescript-eslint/utils" "8.58.0"
|
||||
|
||||
typescript@^5:
|
||||
typescript@^5, "typescript@>= 4.8.x", typescript@>=3.3.1, typescript@>=4.8.4, "typescript@>=4.8.4 <6.1.0", typescript@>=4.9.5:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz"
|
||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||
@@ -5298,12 +4786,17 @@ zod-to-json-schema@^3.24.6, zod-to-json-schema@^3.25.1:
|
||||
resolved "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz"
|
||||
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
|
||||
|
||||
zod@^3.24.1:
|
||||
zod@^3.24.1, "zod@^3.25.28 || ^4":
|
||||
version "3.25.76"
|
||||
resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz"
|
||||
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
|
||||
|
||||
"zod@^3.25 || ^4.0", "zod@^3.25.0 || ^4.0.0":
|
||||
"zod@^3.25 || ^4.0":
|
||||
version "4.3.6"
|
||||
resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz"
|
||||
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
|
||||
|
||||
"zod@^3.25.0 || ^4.0.0":
|
||||
version "4.3.6"
|
||||
resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz"
|
||||
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
|
||||
|
||||
Reference in New Issue
Block a user