mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f09931f352 | |||
| 5b4aa3fa15 | |||
| 3e2221ad5b | |||
| 859a331012 | |||
| c35bec9f89 | |||
| 09e1a0db3a | |||
| 832a8ad00b | |||
| cc8e106a2b | |||
| 079a337f1c | |||
| 6025ac200a | |||
| 248bd5359c | |||
| f65cb59a9c | |||
| d1ea65a41a | |||
| ca02b8b6e7 | |||
| 85aa80938a | |||
| efb4f64014 | |||
| c29cfa3a07 | |||
| 7f706e806f | |||
| 338331d006 | |||
| 6ca2e964e8 | |||
| 1d1aaa7ff3 | |||
| 6da2c4ce95 | |||
| ce8f4d642b | |||
| ae4a73144d | |||
| c57d0c1492 | |||
| 8f8387abff | |||
| 4ae68d01de | |||
| 225ef8c919 | |||
| e4e127880b | |||
| b940ad39f9 | |||
| f45f31b49c | |||
| 978e177715 | |||
| 3861d07cf4 | |||
| 41d45d1210 | |||
| cfe780c6f0 |
+2
-1
@@ -1,5 +1,6 @@
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Dependencies
|
||||
/node_modules
|
||||
@@ -55,4 +56,4 @@ next-env.d.ts
|
||||
/test-media
|
||||
/test-data
|
||||
/bookdrop
|
||||
dockerfile.patch
|
||||
dockerfile.patch
|
||||
|
||||
@@ -403,6 +403,29 @@ echo "🔄 Running Prisma migrations..."
|
||||
cd /app
|
||||
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
|
||||
|
||||
# Run data migrations (run-once SQL scripts tracked in _data_migrations table)
|
||||
echo "🔄 Running data migrations..."
|
||||
|
||||
for sql_file in /app/prisma/data-migrations/*.sql; do
|
||||
if [ -f "$sql_file" ]; then
|
||||
migration_name=$(basename "$sql_file")
|
||||
|
||||
already_run=$(psql "$DATABASE_URL" -tA -c "SELECT 1 FROM _data_migrations WHERE name = '$migration_name' LIMIT 1;")
|
||||
if [ "$already_run" = "1" ]; then
|
||||
echo " Skipping $migration_name (already executed)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " Running $migration_name..."
|
||||
if su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'"; then
|
||||
psql "$DATABASE_URL" -c "INSERT INTO _data_migrations (name) VALUES ('$migration_name');"
|
||||
echo " ✅ $migration_name completed"
|
||||
else
|
||||
echo "⚠️ Data migration $migration_name failed, will retry on next start"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
echo "🔧 Stopping temporary PostgreSQL instance..."
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
- **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md)
|
||||
|
||||
## Reading Shelves (Goodreads, Hardcover)
|
||||
- **Goodreads shelf sync (RSS feeds)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||
- **Hardcover shelf sync (GraphQL API)** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||
- **Shared sync core (Audible lookup, request creation)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||
- **Combined shelves API, GenericShelf** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||
- **Hook factory (createShelfHooks)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#hook-factory)
|
||||
- **Adding a new shelf provider** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||
|
||||
## Audible Integration
|
||||
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
|
||||
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
|
||||
@@ -77,6 +85,7 @@
|
||||
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
|
||||
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
|
||||
- **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md)
|
||||
- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md)
|
||||
|
||||
## BookDate (AI Recommendations)
|
||||
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
|
||||
@@ -150,3 +159,10 @@
|
||||
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
||||
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
|
||||
**"How do I customize my home page?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do Audible categories work?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do I add category sections to the home page?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Goodreads & Shelf Sync
|
||||
|
||||
**Status:** ✅ Implemented | RSS feed parsing, shared sync core, extensible provider architecture
|
||||
|
||||
## Overview
|
||||
Syncs user-subscribed Goodreads shelves via RSS feeds, resolves books to Audible ASINs, and creates requests. Also documents the shared shelf sync core used by all providers.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files
|
||||
- `src/lib/services/goodreads-sync.service.ts` — RSS fetch/parse, delegates to shared core
|
||||
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
|
||||
- `src/lib/utils/shelf-helpers.ts` — Shared `processBooks()` utility for cover URL parsing
|
||||
- `src/lib/hooks/createShelfHooks.ts` — Generic hook factory for shelf CRUD operations
|
||||
- `src/app/api/user/goodreads-shelves/route.ts` — GET (list) + POST (add) routes
|
||||
- `src/app/api/user/goodreads-shelves/[id]/route.ts` — DELETE + PATCH routes
|
||||
- `src/app/api/user/shelves/route.ts` — Combined GET for all providers (GenericShelf shape)
|
||||
- `src/lib/hooks/useGoodreadsShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
|
||||
|
||||
### Database Models
|
||||
- **GoodreadsShelf** — Per-user shelf subscription (`userId`, `rssUrl`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
- **BookMapping** — Shared table for all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN lookups.
|
||||
|
||||
## Goodreads RSS Feed
|
||||
- **Format:** `https://www.goodreads.com/review/list_rss/{userId}?shelf={shelfName}`
|
||||
- **Auth:** None required (public RSS)
|
||||
- **Parsing:** `fast-xml-parser` extracts `item` entries with `book_id`, `title`, `author_name`, `book_image_url`
|
||||
|
||||
## Shared Sync Core
|
||||
|
||||
`shelf-sync-core.service.ts` contains all provider-agnostic sync logic:
|
||||
|
||||
### Interface: `ShelfBook`
|
||||
```typescript
|
||||
{ bookId: string; title: string; author: string; coverUrl?: string }
|
||||
```
|
||||
|
||||
### Function: `processShelfBooks()`
|
||||
Accepts provider-agnostic book list + context, performs:
|
||||
1. **BookMapping lookup** — Check if book already resolved (`provider` + `externalBookId`)
|
||||
2. **Audible search** — Full query (`title author`), fallback with cleaned title (strips parenthetical series info)
|
||||
3. **noMatch retry** — Re-searches after `NO_MATCH_RETRY_DAYS` (7 days)
|
||||
4. **Request creation** — Calls `createRequestForUser()` for matched ASINs
|
||||
5. **Cover enrichment** — Queries `audibleCache` for cached covers, builds `/api/cache/thumbnails/` URLs
|
||||
6. **Shelf metadata update** — Writes `lastSyncAt`, `bookCount`, top 8 books as JSON to `coverUrls`
|
||||
|
||||
### Constants
|
||||
- `DEFAULT_MAX_LOOKUPS_PER_SHELF` = 10 (per scheduled cycle; 0 = unlimited for manual triggers)
|
||||
- `NO_MATCH_RETRY_DAYS` = 7
|
||||
|
||||
### Hook Factory: `createShelfHooks(endpoint)`
|
||||
Returns `{ useList, useAdd, useDelete, useUpdate }` — all with SWR caching, optimistic updates, and automatic revalidation of the combined `/api/user/shelves` endpoint.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/user/goodreads-shelves` | List user's Goodreads shelves |
|
||||
| POST | `/api/user/goodreads-shelves` | Add shelf (validates RSS feed, triggers sync) |
|
||||
| DELETE | `/api/user/goodreads-shelves/[id]` | Remove shelf (ownership check) |
|
||||
| PATCH | `/api/user/goodreads-shelves/[id]` | Update RSS URL (triggers re-sync) |
|
||||
| GET | `/api/user/shelves` | Combined endpoint — merges all providers into `GenericShelf` |
|
||||
|
||||
## Adding a New Provider
|
||||
1. Create Prisma shelf model + migration (BookMapping table is already shared)
|
||||
2. Create API client service for the external data source
|
||||
3. Create thin sync service (~50-80 lines) that fetches books and calls `processShelfBooks()`
|
||||
4. Create API routes (or use a generic route handler)
|
||||
5. Create hook file (~40 lines) using `createShelfHooks(endpoint)`
|
||||
6. Add tab in `AddShelfModal` with provider-specific form fields
|
||||
|
||||
## Related
|
||||
- [Hardcover sync](hardcover-sync.md)
|
||||
- [Background jobs](jobs.md)
|
||||
- [Scheduler](scheduler.md)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Hardcover Shelf Sync
|
||||
|
||||
**Status:** ✅ Implemented | GraphQL API integration, Audible ASIN resolution, automated request creation
|
||||
|
||||
## Overview
|
||||
Syncs user-subscribed Hardcover lists via their GraphQL API, resolves books to Audible ASINs, and creates audiobook requests automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files
|
||||
- `src/lib/services/hardcover-api.service.ts` — GraphQL queries, `fetchHardcoverList()`
|
||||
- `src/lib/services/hardcover-sync.service.ts` — Provider-specific orchestration, delegates to shared core
|
||||
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
|
||||
- `src/app/api/user/hardcover-shelves/route.ts` — GET (list) + POST (add) routes
|
||||
- `src/app/api/user/hardcover-shelves/[id]/route.ts` — DELETE + PATCH routes
|
||||
- `src/lib/hooks/useHardcoverShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
|
||||
|
||||
### Database Models
|
||||
- **HardcoverShelf** — Per-user list subscription (`userId`, `listId`, encrypted `apiToken`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
- **BookMapping** — Shared across all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN resolution (`audibleAsin`, `noMatch`, `lastSearchAt`)
|
||||
|
||||
## Hardcover API
|
||||
|
||||
- **Endpoint:** `https://api.hardcover.app/v1/graphql` (Hasura-based)
|
||||
- **Auth:** Bearer token in Authorization header
|
||||
- **Username type:** `citext` (case-insensitive text) — use `$username: citext!` in GraphQL variables
|
||||
|
||||
### Query Strategies (custom lists)
|
||||
| Input | Strategy | Query root |
|
||||
|---|---|---|
|
||||
| URL with `@username` | Scoped to that user | `users(where: {username: {_eq: $username}}) { lists(...) }` |
|
||||
| Bare slug (no username) | Authenticated user's own list | `me { lists(where: {slug: {_eq: $slug}}) }` |
|
||||
| Numeric ID | Global lookup (IDs are unique) | `lists(where: {id: {_eq: $listId}})` |
|
||||
|
||||
### Status Lists
|
||||
- Prefix: `status-{id}` (e.g., `status-1`)
|
||||
- Query: `me { user_books(where: {status_id: {_eq: $statusId}}) }`
|
||||
- Status IDs: 1=Want to Read, 2=Currently Reading, 3=Read, 4=Did Not Finish
|
||||
|
||||
## Sync Flow
|
||||
1. Fetch shelves from DB (all or specific `shelfId`)
|
||||
2. Decrypt API token (encryption service)
|
||||
3. Fetch books from Hardcover GraphQL API
|
||||
4. Delegate to `processShelfBooks()` in shelf-sync-core (Audible lookup, request creation, cover enrichment)
|
||||
5. Update shelf metadata (`lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/user/hardcover-shelves` | List user's shelves with book counts/covers |
|
||||
| POST | `/api/user/hardcover-shelves` | Add new shelf (validates via API fetch, encrypts token, triggers sync) |
|
||||
| DELETE | `/api/user/hardcover-shelves/[id]` | Remove shelf (ownership check) |
|
||||
| PATCH | `/api/user/hardcover-shelves/[id]` | Update listId/apiToken (triggers re-sync on change) |
|
||||
|
||||
## Key Details
|
||||
- **Token cleanup:** Strips `Bearer ` prefix if user pastes it
|
||||
- **Duplicate check:** Unique constraint on `(userId, listId)`
|
||||
- **Immediate sync:** POST and PATCH trigger `addSyncShelvesJob()` with unlimited lookups
|
||||
- **Scheduled sync:** Runs via `sync_reading_shelves` job (default: max 10 lookups/shelf/cycle)
|
||||
- **Cover data:** Stores top 8 books as JSON in `coverUrls` field for shelf card display
|
||||
|
||||
## Related
|
||||
- [Shelf sync core (shared logic)](goodreads-sync.md#shared-sync-core)
|
||||
- [Background jobs](jobs.md)
|
||||
- [Scheduler](scheduler.md)
|
||||
@@ -129,10 +129,10 @@ interface ScheduledJob {
|
||||
## Audible Refresh Processor
|
||||
|
||||
**Implementation:**
|
||||
1. Clear previous `isPopular`/`isNewRelease` flags
|
||||
2. Fetch 200 popular + 200 new releases (multi-page scraping)
|
||||
3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
|
||||
4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths
|
||||
1. Fetch 200 popular + 200 new releases (multi-page scraping)
|
||||
2. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
|
||||
3. Wipe and re-populate `AudibleCacheCategory` entries with reserved IDs (`__popular__`, `__new_releases__`) and user-configured category IDs
|
||||
4. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory`
|
||||
5. Record sync timestamp (`lastAudibleSync`)
|
||||
6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache)
|
||||
7. Perform fuzzy matching (70% threshold) against Plex library
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Home Page Sections (Per-User Configurable)
|
||||
|
||||
**Status:** Implemented | Per-user home page with configurable sections (popular, new releases, Audible categories)
|
||||
|
||||
## Overview
|
||||
Users customize their home page by adding/removing/reordering sections. Each section displays audiobooks from a specific source: built-in Popular, New Releases, or scraped Audible categories.
|
||||
|
||||
## Data Models
|
||||
|
||||
**UserHomeSection** (`user_home_sections`):
|
||||
- `id`, `userId` (FK User), `sectionType` ('popular'|'new_releases'|'category'), `categoryId` (nullable), `categoryName` (nullable), `sortOrder` (int)
|
||||
- Unique: `(userId, sectionType, categoryId)`
|
||||
- Default: Popular (0) + New Releases (1) created on first access
|
||||
|
||||
**AudibleCacheCategory** (`audible_cache_categories`):
|
||||
- `id`, `asin`, `categoryId`, `rank`, `lastSyncedAt`
|
||||
- Unique: `(asin, categoryId)`, Indexes: `categoryId`, `(categoryId, rank)`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/user/home-sections` | user | Returns sections + nextRefresh |
|
||||
| PUT | `/api/user/home-sections` | user | Save full config (delete-recreate), max 10 |
|
||||
| GET | `/api/audible/categories` | user | Live scrape top-level categories |
|
||||
| GET | `/api/audiobooks/category/[categoryId]` | public | Paginated category books from cache |
|
||||
|
||||
## Refresh Processor (Unified Storage)
|
||||
- All section data stored in `AudibleCacheCategory` with reserved IDs: `__popular__` and `__new_releases__` for built-in sections
|
||||
- Popular/new-releases use same wipe-and-populate pattern as user categories
|
||||
- After built-in sections, queries DISTINCT categoryIds from `UserHomeSection`
|
||||
- Per section: wipe `AudibleCacheCategory` rows, scrape, upsert `AudibleCache` metadata, insert ranked category entries
|
||||
- Batch cooldown between sections (10-20s random)
|
||||
- Constants exported from `audible-refresh.processor.ts`: `POPULAR_CATEGORY_ID`, `NEW_RELEASES_CATEGORY_ID`
|
||||
|
||||
## AudibleService Methods
|
||||
- `getCategories()`: Scrapes `{baseUrl}/categories`, returns `{id, name}[]`
|
||||
- `getCategoryBooks(categoryId, limit)`: Scrapes `/search?node={id}&pageSize=50&sort=popularity-rank`, up to 200 results
|
||||
|
||||
## Frontend
|
||||
- **Hooks:** `useHomeSections()`, `useCategoryAudiobooks()`, `useAudibleCategories()` in `src/lib/hooks/useHomeSections.ts`
|
||||
- **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce
|
||||
- **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header
|
||||
- **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize
|
||||
- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — updated to support 1-12 dynamic sections
|
||||
|
||||
## Key Decisions
|
||||
- 10 section limit per user (total)
|
||||
- Category picker scraped live (no categories table)
|
||||
- Top-level categories only (v1)
|
||||
- Wipe-and-re-scrape per category during refresh
|
||||
- Deduplication of categories across users before scraping
|
||||
- If category disappears, user sees empty section
|
||||
- 10-color palette assigned by sort order
|
||||
|
||||
## Files
|
||||
- Schema: `prisma/schema.prisma` (UserHomeSection, AudibleCacheCategory)
|
||||
- Migration: `prisma/migrations/20260306000000_add_home_sections/migration.sql`
|
||||
- Service: `src/lib/integrations/audible.service.ts` (getCategories, getCategoryBooks)
|
||||
- Processor: `src/lib/processors/audible-refresh.processor.ts`
|
||||
- API Routes: `src/app/api/user/home-sections/route.ts`, `src/app/api/audible/categories/route.ts`, `src/app/api/audiobooks/category/[categoryId]/route.ts`
|
||||
- Hooks: `src/lib/hooks/useHomeSections.ts`
|
||||
- Components: `src/components/home/HomeSectionConfigModal.tsx`, `src/components/home/HomeSection.tsx`
|
||||
- Tests: `tests/api/home-sections.routes.test.ts`, `tests/processors/audible-refresh.processor.test.ts`
|
||||
@@ -128,11 +128,11 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
||||
Discovery APIs serve cached data from DB with real-time matching.
|
||||
|
||||
**Flow:**
|
||||
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases
|
||||
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases + user-configured categories
|
||||
2. Downloads and caches cover thumbnails locally (reduces Audible load)
|
||||
3. Stores in DB with flags (`isPopular`, `isNewRelease`) and rankings
|
||||
3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs
|
||||
4. Cleans up unused thumbnails after sync
|
||||
5. API routes query DB → apply real-time matching → return enriched results
|
||||
5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results
|
||||
6. Homepage loads instantly (no Audible API hits)
|
||||
|
||||
## Thumbnail Caching
|
||||
|
||||
@@ -51,7 +51,7 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Base URL for mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
|
||||
|
||||
#### Section 2: Indexer Search
|
||||
@@ -180,18 +180,18 @@ Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191`
|
||||
|
||||
### Method 1: ASIN Search (exact match)
|
||||
```
|
||||
Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
||||
Search: https://annas-archive.gl/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
||||
↓
|
||||
MD5 Page: https://annas-archive.li/md5/[md5]
|
||||
MD5 Page: https://annas-archive.gl/md5/[md5]
|
||||
↓
|
||||
Slow Download: https://annas-archive.li/slow_download/[md5]/0/5
|
||||
Slow Download: https://annas-archive.gl/slow_download/[md5]/0/5
|
||||
↓
|
||||
File Server: http://[server]/path/to/file.epub
|
||||
```
|
||||
|
||||
### Method 2: Title + Author (fallback)
|
||||
```
|
||||
Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en
|
||||
Search: https://annas-archive.gl/search?q=Title+Author&ext=epub&lang=en
|
||||
↓ (Same flow from MD5 page)
|
||||
```
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ src/app/admin/settings/
|
||||
|
||||
1. **Anna's Archive Section**
|
||||
- Enable toggle for Anna's Archive downloads
|
||||
- Base URL (default: `https://annas-archive.li`)
|
||||
- Base URL (default: `https://annas-archive.gl`)
|
||||
- FlareSolverr URL (optional, for Cloudflare bypass)
|
||||
|
||||
2. **Indexer Search Section**
|
||||
@@ -101,7 +101,7 @@ src/app/admin/settings/
|
||||
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
|
||||
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
|
||||
| `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Anna's Archive mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
||||
|
||||
**Behavior:**
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.15",
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
@@ -299,7 +299,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -309,7 +309,7 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -343,7 +343,7 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.5"
|
||||
@@ -403,7 +403,7 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.16",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Normalize existing local usernames to lowercase (idempotent - safe to run multiple times)
|
||||
-- Only affects local auth users, not Plex/OIDC users
|
||||
UPDATE users SET plex_username = LOWER(plex_username)
|
||||
WHERE auth_provider = 'local' AND deleted_at IS NULL AND plex_username != LOWER(plex_username);
|
||||
|
||||
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7))
|
||||
WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%' AND plex_id != LOWER(plex_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Reset works table to fix incorrect dedup groupings (v1.1.2)
|
||||
-- Books with "Series: Title" naming (e.g. "Eden's Gate: The Reborn" vs
|
||||
-- "Eden's Gate: The Spartan") were incorrectly merged into the same work
|
||||
-- because subtitle stripping collapsed them to the same base title.
|
||||
-- The works table auto-rebuilds from dedup logic as users browse.
|
||||
DELETE FROM work_asins;
|
||||
DELETE FROM works;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "hardcover_shelves" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"list_id" TEXT NOT NULL,
|
||||
"api_token" TEXT NOT NULL,
|
||||
"last_sync_at" TIMESTAMP(3),
|
||||
"book_count" INTEGER,
|
||||
"cover_urls" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "hardcover_shelves_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "hardcover_book_mappings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"hardcover_book_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"audible_asin" TEXT,
|
||||
"cover_url" TEXT,
|
||||
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_search_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "hardcover_book_mappings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_shelves_user_id_idx" ON "hardcover_shelves"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hardcover_shelves_user_id_list_id_key" ON "hardcover_shelves"("user_id", "list_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hardcover_book_mappings_hardcover_book_id_key" ON "hardcover_book_mappings"("hardcover_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_book_mappings_hardcover_book_id_idx" ON "hardcover_book_mappings"("hardcover_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_book_mappings_audible_asin_idx" ON "hardcover_book_mappings"("audible_asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "hardcover_shelves" ADD CONSTRAINT "hardcover_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "book_mappings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"external_book_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"audible_asin" TEXT,
|
||||
"cover_url" TEXT,
|
||||
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_search_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "book_mappings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Migrate data from goodreads_book_mappings
|
||||
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
|
||||
SELECT "id", 'goodreads', "goodreads_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
|
||||
FROM "goodreads_book_mappings";
|
||||
|
||||
-- Migrate data from hardcover_book_mappings
|
||||
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
|
||||
SELECT "id", 'hardcover', "hardcover_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
|
||||
FROM "hardcover_book_mappings";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "goodreads_book_mappings";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "hardcover_book_mappings";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "book_mappings_provider_external_book_id_key" ON "book_mappings"("provider", "external_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "book_mappings_provider_external_book_id_idx" ON "book_mappings"("provider", "external_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "book_mappings_audible_asin_idx" ON "book_mappings"("audible_asin");
|
||||
@@ -0,0 +1,49 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_home_sections" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"section_type" TEXT NOT NULL,
|
||||
"category_id" TEXT,
|
||||
"category_name" TEXT,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_home_sections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audible_cache_categories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"asin" TEXT NOT NULL,
|
||||
"category_id" TEXT NOT NULL,
|
||||
"rank" INTEGER NOT NULL,
|
||||
"last_synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audible_cache_categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_home_sections_user_id_idx" ON "user_home_sections"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_home_sections_sort_order_idx" ON "user_home_sections"("sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_home_sections_user_id_section_type_category_id_key" ON "user_home_sections"("user_id", "section_type", "category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_category_id_idx" ON "audible_cache_categories"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_asin_idx" ON "audible_cache_categories"("asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_category_id_rank_idx" ON "audible_cache_categories"("category_id", "rank");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "audible_cache_categories_asin_category_id_key" ON "audible_cache_categories"("asin", "category_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_home_sections" ADD CONSTRAINT "user_home_sections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_is_popular_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_is_new_release_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_popular_rank_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_new_release_rank_idx";
|
||||
|
||||
-- AlterTable - Remove legacy discovery flag columns (now stored in audible_cache_categories)
|
||||
ALTER TABLE "audible_cache" DROP COLUMN "is_popular",
|
||||
DROP COLUMN "is_new_release",
|
||||
DROP COLUMN "popular_rank",
|
||||
DROP COLUMN "new_release_rank";
|
||||
+106
-23
@@ -66,12 +66,14 @@ model User {
|
||||
bookDateRecommendations BookDateRecommendation[]
|
||||
bookDateSwipes BookDateSwipe[]
|
||||
goodreadsShelves GoodreadsShelf[]
|
||||
hardcoverShelves HardcoverShelf[]
|
||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||
watchedSeries WatchedSeries[]
|
||||
watchedAuthors WatchedAuthor[]
|
||||
homeSections UserHomeSection[]
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -98,12 +100,6 @@ model AudibleCache {
|
||||
rating Decimal? @db.Decimal(3, 2)
|
||||
genres Json @default("[]")
|
||||
|
||||
// Discovery categories
|
||||
isPopular Boolean @default(false) @map("is_popular")
|
||||
isNewRelease Boolean @default(false) @map("is_new_release")
|
||||
popularRank Int? @map("popular_rank")
|
||||
newReleaseRank Int? @map("new_release_rank")
|
||||
|
||||
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -111,10 +107,6 @@ model AudibleCache {
|
||||
@@index([asin])
|
||||
@@index([title])
|
||||
@@index([author])
|
||||
@@index([isPopular])
|
||||
@@index([isNewRelease])
|
||||
@@index([popularRank])
|
||||
@@index([newReleaseRank])
|
||||
@@map("audible_cache")
|
||||
}
|
||||
|
||||
@@ -547,21 +539,54 @@ model GoodreadsShelf {
|
||||
@@map("goodreads_shelves")
|
||||
}
|
||||
|
||||
model GoodreadsBookMapping {
|
||||
id String @id @default(uuid())
|
||||
goodreadsBookId String @unique @map("goodreads_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
// ============================================================================
|
||||
// UNIFIED BOOK MAPPING TABLE
|
||||
// Global book-to-ASIN mapping cache shared across all shelf providers.
|
||||
// Uses provider + externalBookId composite key for cross-provider dedup.
|
||||
// ============================================================================
|
||||
|
||||
@@index([goodreadsBookId])
|
||||
model BookMapping {
|
||||
id String @id @default(uuid())
|
||||
provider String // "goodreads", "hardcover", etc.
|
||||
externalBookId String @map("external_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([provider, externalBookId])
|
||||
@@index([provider, externalBookId])
|
||||
@@index([audibleAsin])
|
||||
@@map("goodreads_book_mappings")
|
||||
@@map("book_mappings")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HARDCOVER SYNC TABLES
|
||||
// Per-user Hardcover list subscriptions
|
||||
// ============================================================================
|
||||
|
||||
model HardcoverShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
name String // Extracted from Hardcover API list name or status
|
||||
listId String @map("list_id") // Hardcover List ID or Status ID
|
||||
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
bookCount Int? @map("book_count")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, listId])
|
||||
@@index([userId])
|
||||
@@map("hardcover_shelves")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -647,3 +672,61 @@ model WatchedAuthor {
|
||||
@@index([authorAsin])
|
||||
@@map("watched_authors")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER HOME SECTION TABLE
|
||||
// Per-user configurable home page sections (popular, new_releases, category)
|
||||
// Documentation: documentation/features/home-sections.md
|
||||
// ============================================================================
|
||||
|
||||
model UserHomeSection {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category'
|
||||
categoryId String? @map("category_id") // Audible category node ID (only for type 'category')
|
||||
categoryName String? @map("category_name") // Display name (only for type 'category')
|
||||
sortOrder Int @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, sectionType, categoryId])
|
||||
@@index([userId])
|
||||
@@index([sortOrder])
|
||||
@@map("user_home_sections")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIBLE CACHE CATEGORY TABLE
|
||||
// Join table linking AudibleCache entries to Audible categories with ranking
|
||||
// Documentation: documentation/features/home-sections.md
|
||||
// ============================================================================
|
||||
|
||||
model AudibleCacheCategory {
|
||||
id String @id @default(uuid())
|
||||
asin String
|
||||
categoryId String @map("category_id")
|
||||
rank Int
|
||||
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([asin, categoryId])
|
||||
@@index([categoryId])
|
||||
@@index([asin])
|
||||
@@index([categoryId, rank])
|
||||
@@map("audible_cache_categories")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATA MIGRATION TRACKING
|
||||
// Tracks which data migration SQL scripts have been executed (run-once).
|
||||
// ============================================================================
|
||||
|
||||
model DataMigration {
|
||||
name String @id
|
||||
executedAt DateTime @default(now()) @map("executed_at")
|
||||
|
||||
@@map("_data_migrations")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-9 group-hover:rotate-12 transition-all duration-300" fill="none" viewBox="0 0 40 40"><path d="M12.8889 32.5982C12.666 31.7661 13.1598 30.9108 13.9919 30.6879L30.2971 26.3189C31.1292 26.096 31.9845 26.5898 32.2075 27.4219L32.8739 29.9089C33.1711 31.0183 32.5127 32.1587 31.4033 32.456L18.1113 36.0176C15.8924 36.6121 13.6116 35.2953 13.0171 33.0764L12.8889 32.5982Z" fill="#4F46E5"></path><path d="M7.62314 12.946C7.05137 10.8121 8.3177 8.61876 10.4516 8.04699L16.8851 32.0571L13.0214 33.0924L7.62314 12.946Z" fill="#4F46E5"></path><path d="M29.3358 24.432L31.2677 23.9144L32.3584 27.985C32.6443 29.052 32.0111 30.1486 30.9442 30.4345L29.3358 24.432Z" fill="#4338CA"></path><path d="M26.4446 5.91475C26.1474 4.80529 25.007 4.14688 23.8975 4.44416L10.5286 8.02636C9.41911 8.32364 8.7607 9.46403 9.05798 10.5735L14.9532 32.5748L22.6461 30.5135C23.1986 30.3654 23.5265 29.7975 23.3785 29.245C23.2304 28.6925 23.5583 28.1245 24.1108 27.9765L29.7949 26.4535C30.9043 26.1562 31.5628 25.0158 31.2655 23.9063L26.4446 5.91475Z" fill="#6366F1"></path><path d="M21.0947 11.2811C21.145 10.6645 21.9408 10.4512 22.2927 10.9601L22.442 11.1761C22.5512 11.3341 22.724 11.4365 22.9151 11.4565L23.2375 11.4902C23.838 11.553 24.0445 12.3235 23.5558 12.6781L23.2935 12.8685C23.138 12.9813 23.0395 13.1564 23.0239 13.3479L23.0026 13.6096C22.9523 14.2262 22.1564 14.4394 21.8046 13.9306L21.6553 13.7146C21.546 13.5566 21.3732 13.4542 21.1821 13.4342L20.8598 13.4005C20.2592 13.3377 20.0528 12.5672 20.5415 12.2126L20.8038 12.0222C20.9593 11.9094 21.0577 11.7343 21.0734 11.5428L21.0947 11.2811Z" fill="#312E81"></path><path d="M18.3031 16.3181C18.3533 15.7015 19.1492 15.4882 19.501 15.9971L20.5634 17.5337C20.6727 17.6917 20.8455 17.7941 21.0366 17.8141L22.9139 18.0104C23.5144 18.0732 23.7208 18.8436 23.2321 19.1983L21.7045 20.3069C21.549 20.4197 21.4506 20.5949 21.435 20.7863L21.2832 22.6482C21.2329 23.2649 20.4371 23.4781 20.0852 22.9692L19.0228 21.4327C18.9136 21.2747 18.7407 21.1722 18.5497 21.1522L16.6724 20.956C16.0719 20.8932 15.8654 20.1227 16.3541 19.7681L17.8817 18.6594C18.0372 18.5466 18.1357 18.3715 18.1513 18.18L18.3031 16.3181Z" fill="#312E81"></path><path d="M14.9532 32.5748C14.6571 31.4697 15.3129 30.3339 16.4179 30.0378L29.8719 26.4328L30.9441 30.4345L17.4902 34.0395C16.3851 34.3356 15.2493 33.6798 14.9532 32.5748Z" fill="#EEF2FF"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 41.2 (35397) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>img-coverart</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<rect id="path-1" x="0" y="0" width="500" height="500"></rect>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Account-details:-membership-asin-doc" transform="translate(-87.000000, -867.000000)">
|
||||
<g id="Group" transform="translate(65.000000, 780.000000)">
|
||||
<g id="img-coverart" transform="translate(22.000000, 87.000000)">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<use id="mask" fill="#BBBBBB" xlink:href="#path-1"></use>
|
||||
<path d="M251.314605,307.191176 L126.315789,229.090627 L126.315789,250.186562 L251.314605,328.289474 L376.315789,250.186562 L376.315789,229.090627 L251.314605,307.191176 Z M300.338486,257.198698 L318.743522,245.697622 L318.757695,245.697622 C304.238718,223.902504 279.436447,209.540923 251.277279,209.540923 C223.146464,209.540923 198.363093,223.878883 183.839389,245.643293 L183.952782,245.655104 C184.933157,244.762229 185.930063,243.885889 186.955321,243.03317 C222.033803,213.960416 272.668324,220.342816 300.338486,257.198698 Z M214.370819,264.53208 C220.980666,259.892912 228.629944,257.226098 236.796575,257.226098 C250.228874,257.226098 262.283922,264.413975 270.556862,275.811119 L288.30989,264.716324 L288.319343,264.716324 C280.157438,253.040453 266.61174,245.39669 251.277753,245.39669 C236.026448,245.39669 222.549266,252.95778 214.370819,264.53208 Z M166.789394,213.901363 C218.255462,173.164548 291.088955,184.079823 329.878678,238.171964 L330.136173,238.568797 L349.186133,226.701596 C328.31953,194.777784 292.263042,173.684211 251.278701,173.684211 C210.866048,173.684211 174.47174,194.572281 153.416152,226.633095 C157.283311,222.56083 162.29621,217.458689 166.789394,213.901363 Z" id="icn-audible" fill="#FFFFFF" mask="url(#mask-2)"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -163,7 +163,7 @@ function getInitialParams(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) {
|
||||
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.gl' }: RecentRequestsTableProps) {
|
||||
const toast = useToast();
|
||||
|
||||
// Get initial filter state from URL (only evaluated once due to lazy init)
|
||||
|
||||
@@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{issue.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function RequestActionsDropdown({
|
||||
onFetchEbook,
|
||||
onSearchTermsUpdated,
|
||||
ebookSidecarEnabled = false,
|
||||
annasArchiveBaseUrl = 'https://annas-archive.li',
|
||||
annasArchiveBaseUrl = 'https://annas-archive.gl',
|
||||
isLoading = false,
|
||||
}: RequestActionsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -62,10 +62,9 @@ export function RequestActionsDropdown({
|
||||
// View Details: available when ASIN exists (audiobook requests only)
|
||||
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
||||
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
// Determine available actions based on status
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
@@ -130,7 +129,11 @@ export function RequestActionsDropdown({
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearch(true);
|
||||
if (isEbook) {
|
||||
setShowInteractiveSearchEbook(true);
|
||||
} else {
|
||||
setShowInteractiveSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdjustSearchTerms = () => {
|
||||
@@ -513,6 +516,7 @@ export function RequestActionsDropdown({
|
||||
author: request.author,
|
||||
}}
|
||||
searchMode="ebook"
|
||||
customSearchTerms={request.customSearchTerms}
|
||||
/>
|
||||
|
||||
{/* Adjust Search Terms Modal */}
|
||||
|
||||
+7
-17
@@ -176,23 +176,13 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{request.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={request.audiobook.coverArtUrl}
|
||||
alt={request.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={request.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Book Info */}
|
||||
|
||||
@@ -90,9 +90,9 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.baseUrl || 'https://annas-archive.li'}
|
||||
value={ebook.baseUrl || 'https://annas-archive.gl'}
|
||||
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
||||
placeholder="https://annas-archive.li"
|
||||
placeholder="https://annas-archive.gl"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: ebook.flaresolverrUrl,
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
annasArchiveEnabled: ebook.annasArchiveEnabled || false,
|
||||
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
|
||||
format: ebook.preferredFormat || 'epub',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
|
||||
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
||||
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
|
||||
kindleFixEnabled: ebook.kindleFixEnabled ?? false,
|
||||
|
||||
@@ -155,10 +155,42 @@ export async function POST(request: NextRequest) {
|
||||
audiobookId = newBook.id;
|
||||
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
// Not in DB — fetch live from Audnexus and create a record
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const liveData = await audibleService.getAudiobookDetails(asin);
|
||||
if (liveData) {
|
||||
const newBook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: liveData.title,
|
||||
author: liveData.author,
|
||||
coverArtUrl: liveData.coverArtUrl,
|
||||
narrator: liveData.narrator,
|
||||
series: liveData.series,
|
||||
seriesPart: liveData.seriesPart,
|
||||
seriesAsin: liveData.seriesAsin,
|
||||
year: liveData.releaseDate
|
||||
? new Date(liveData.releaseDate).getFullYear() || undefined
|
||||
: undefined,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
audiobookId = newBook.id;
|
||||
logger.info(`Created audiobook record from Audnexus for ASIN ${asin}: ${newBook.id}`);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
} catch (audnexusError) {
|
||||
logger.error(`Failed to fetch ASIN ${asin} from Audnexus: ${audnexusError instanceof Error ? audnexusError.message : String(audnexusError)}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function PUT(request: NextRequest) {
|
||||
// Anna's Archive specific settings
|
||||
{
|
||||
key: 'ebook_sidecar_base_url',
|
||||
value: baseUrl || 'https://annas-archive.li',
|
||||
value: baseUrl || 'https://annas-archive.gl',
|
||||
category: 'ebook',
|
||||
description: 'Base URL for Anna\'s Archive',
|
||||
},
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function GET(request: NextRequest) {
|
||||
(configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'),
|
||||
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
|
||||
// Anna's Archive specific settings
|
||||
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li',
|
||||
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.gl',
|
||||
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
|
||||
// General settings
|
||||
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Component: Audible Categories API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Live scrape of top-level Audible categories for the home section config modal.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Audible.Categories');
|
||||
|
||||
/**
|
||||
* GET /api/audible/categories
|
||||
* Returns top-level Audible categories scraped live from audible.com/categories
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (_req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||
const audibleService = getAudibleService();
|
||||
const categories = await audibleService.getCategories();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
categories,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch categories', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch Audible categories' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -227,7 +227,7 @@ export async function POST(
|
||||
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
||||
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
|
||||
|
||||
// Get language code from Audible region config
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: Category Audiobooks API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Serves audiobooks for a specific Audible category from AudibleCacheCategory,
|
||||
* with the same enrichment pattern as popular/new-releases routes.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/category/[categoryId]?page=1&limit=20&hideAvailable=false
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ categoryId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { categoryId } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Invalid pagination parameters.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Get excluded ASINs when hideAvailable
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
// Query AudibleCacheCategory joined with AudibleCache
|
||||
const whereClause: any = { categoryId };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
if (totalCount === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: [],
|
||||
count: 0,
|
||||
totalCount: 0,
|
||||
page,
|
||||
totalPages: 0,
|
||||
hasMore: false,
|
||||
message: 'No audiobooks found for this category. Data may not have been refreshed yet.',
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Enrich with library matching and request status
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get category audiobooks', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch category audiobooks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
* Component: Audiobook Covers API Route
|
||||
* Documentation: documentation/frontend/pages/login.md
|
||||
*
|
||||
* Serves random popular audiobook covers for login page floating animations
|
||||
* Serves random popular audiobook covers for login page floating animations.
|
||||
* Queries AudibleCacheCategory with '__popular__' categoryId for cover sources.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||
|
||||
@@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Fetch all popular audiobooks with covers (up to 200)
|
||||
// Get popular ASINs from category table (up to 200)
|
||||
const categoryEntries = await prisma.audibleCacheCategory.findMany({
|
||||
where: { categoryId: POPULAR_CATEGORY_ID },
|
||||
orderBy: { rank: 'asc' },
|
||||
take: 200,
|
||||
select: { asin: true },
|
||||
});
|
||||
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
|
||||
// Fetch cover data from AudibleCache for popular ASINs with cached covers
|
||||
const audiobooks = await prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isPopular: true,
|
||||
cachedCoverPath: {
|
||||
not: null,
|
||||
},
|
||||
asin: { in: asins },
|
||||
cachedCoverPath: { not: null },
|
||||
},
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
take: 200,
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Component: New Releases API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Serves new release audiobooks from audible_cache with real-time Plex matching
|
||||
* Serves new release audiobooks from AudibleCacheCategory with real-time library matching.
|
||||
* New releases are stored with categoryId '__new_releases__' in the unified category table.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -10,12 +11,13 @@ import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/new-releases?page=1&limit=20
|
||||
* Get new release audiobooks from audible_cache with pagination
|
||||
* Get new release audiobooks from AudibleCacheCategory with pagination
|
||||
*
|
||||
* Real-time matching against plex_library determines availability.
|
||||
*/
|
||||
@@ -46,39 +48,21 @@ export async function GET(request: NextRequest) {
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isNewRelease: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
// Query audible_cache for new release audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
// Query AudibleCacheCategory for new release audiobooks
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
newReleaseRank: 'asc',
|
||||
},
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: whereClause,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// If no data found, return helpful message
|
||||
@@ -95,30 +79,56 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Transform to matcher input format (uses ASIN as required field)
|
||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
||||
const audibleBooks = audiobooks.map((book) => {
|
||||
// Convert cached path to API URL if it exists
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Get current user (optional - for request status enrichment)
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
@@ -137,7 +147,7 @@ export async function GET(request: NextRequest) {
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Component: Popular Audiobooks API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Serves popular audiobooks from audible_cache with real-time Plex matching
|
||||
* Serves popular audiobooks from AudibleCacheCategory with real-time library matching.
|
||||
* Popular books are stored with categoryId '__popular__' in the unified category table.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -10,12 +11,13 @@ import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/popular?page=1&limit=20
|
||||
* Get popular audiobooks from audible_cache with pagination
|
||||
* Get popular audiobooks from AudibleCacheCategory with pagination
|
||||
*
|
||||
* Real-time matching against plex_library determines availability.
|
||||
*/
|
||||
@@ -46,39 +48,21 @@ export async function GET(request: NextRequest) {
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isPopular: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
// Query audible_cache for popular audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
// Query AudibleCacheCategory for popular audiobooks
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: whereClause,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// If no data found, return helpful message
|
||||
@@ -95,30 +79,56 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Transform to matcher input format (uses ASIN as required field)
|
||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
||||
const audibleBooks = audiobooks.map((book) => {
|
||||
// Convert cached path to API URL if it exists
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Get current user (optional - for request status enrichment)
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
@@ -137,7 +147,7 @@ export async function GET(request: NextRequest) {
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -71,41 +71,56 @@ export async function POST(
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const customTitle = body.customTitle as string | undefined;
|
||||
|
||||
// Get the parent audiobook request
|
||||
const parentRequest = await prisma.request.findUnique({
|
||||
// Get the request (can be audiobook parent or direct ebook request)
|
||||
const requestRecord = await prisma.request.findUnique({
|
||||
where: { id: parentRequestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!parentRequest) {
|
||||
if (!requestRecord) {
|
||||
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parentRequest.type !== 'audiobook') {
|
||||
return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 });
|
||||
// Support two flows:
|
||||
// Flow A (sidecar): Audiobook request in downloaded/available state
|
||||
// Flow B (direct): Ebook request in pending/failed/awaiting_search state
|
||||
const isDirectEbookSearch = requestRecord.type === 'ebook';
|
||||
const isAudiobookSidecar = requestRecord.type === 'audiobook';
|
||||
|
||||
if (!isDirectEbookSearch && !isAudiobookSidecar) {
|
||||
return NextResponse.json({ error: 'Invalid request type' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['downloaded', 'available'].includes(parentRequest.status)) {
|
||||
if (isAudiobookSidecar && !['downloaded', 'available'].includes(requestRecord.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot search ebooks for request in ${parentRequest.status} status` },
|
||||
{ error: `Cannot search ebooks for audiobook request in ${requestRecord.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing non-retryable ebook request
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (isDirectEbookSearch && !['pending', 'failed', 'awaiting_search'].includes(requestRecord.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot search for ebook request in ${requestRecord.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
existingRequestId: existingEbookRequest.id,
|
||||
}, { status: 400 });
|
||||
// Check for existing child ebook requests (sidecar mode only)
|
||||
if (isAudiobookSidecar) {
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
existingRequestId: existingEbookRequest.id,
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Get ebook configuration
|
||||
@@ -121,7 +136,7 @@ export async function POST(
|
||||
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
||||
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
|
||||
|
||||
// Get language code from Audible region config
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
@@ -135,10 +150,10 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const audiobook = parentRequest.audiobook;
|
||||
const audiobook = requestRecord.audiobook;
|
||||
const searchTitle = customTitle || audiobook.title;
|
||||
|
||||
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`);
|
||||
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author} (${isDirectEbookSearch ? 'direct' : 'sidecar'})`);
|
||||
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
|
||||
|
||||
// Search both sources in parallel
|
||||
|
||||
@@ -196,10 +196,10 @@ export async function POST(
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
|
||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||
// Always use the audiobook's title/author for ranking (not custom search query)
|
||||
// Use searchTitle for ranking so custom search terms and search bar overrides are respected
|
||||
// requireAuthor: false - interactive mode, show all results for user decision
|
||||
const rankedResults = rankTorrents(results, {
|
||||
title: requestRecord.audiobook.title,
|
||||
title: searchTitle,
|
||||
author: requestRecord.audiobook.author,
|
||||
durationMinutes,
|
||||
}, {
|
||||
@@ -218,7 +218,7 @@ export async function POST(
|
||||
const top3 = rankedResults.slice(0, 3);
|
||||
if (top3.length > 0) {
|
||||
logger.debug('==================== RANKING DEBUG ====================');
|
||||
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
||||
logger.debug('Search parameters', { searchTitle, rankingTitle: searchTitle, audiobookTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
||||
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
|
||||
logger.debug('--------------------------------------------------------');
|
||||
top3.forEach((result, index) => {
|
||||
|
||||
@@ -64,14 +64,20 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger search job
|
||||
// Trigger appropriate search job based on request type
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(id, {
|
||||
const audiobookData = {
|
||||
id: requestRecord.audiobook.id,
|
||||
title: requestRecord.audiobook.title,
|
||||
author: requestRecord.audiobook.author,
|
||||
asin: requestRecord.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (requestRecord.type === 'ebook') {
|
||||
await jobQueue.addSearchEbookJob(id, audiobookData);
|
||||
} else {
|
||||
await jobQueue.addSearchJob(id, audiobookData);
|
||||
}
|
||||
|
||||
// Update request status
|
||||
const updated = await prisma.request.update({
|
||||
|
||||
@@ -7,9 +7,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||
|
||||
const UpdateGoodreadsSchema = z.object({
|
||||
rssUrl: z.string().url('Must be a valid URL'),
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/user/goodreads-shelves/[id]
|
||||
* Remove a Goodreads shelf subscription (ownership check)
|
||||
@@ -48,3 +54,57 @@ export async function DELETE(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/user/goodreads-shelves/[id]
|
||||
* Update a Goodreads shelf subscription
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const shelf = await prisma.goodreadsShelf.findUnique({ where: { id } });
|
||||
|
||||
if (!shelf) {
|
||||
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (shelf.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { rssUrl } = UpdateGoodreadsSchema.parse(body);
|
||||
|
||||
// Force re-fetch by clearing metadata
|
||||
const updated = await prisma.goodreadsShelf.update({
|
||||
where: { id },
|
||||
data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null },
|
||||
});
|
||||
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, shelf: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
|
||||
}
|
||||
logger.error('Failed to update shelf', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to update shelf' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ export async function POST(request: NextRequest) {
|
||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
|
||||
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
|
||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
|
||||
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Component: Hardcover Shelf Delete Route
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.HardcoverShelves');
|
||||
|
||||
const UpdateHardcoverSchema = z.object({
|
||||
listId: z.string().min(1, 'List ID is required').optional(),
|
||||
apiToken: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/user/hardcover-shelves/[id]
|
||||
* Remove a Hardcover shelf subscription (ownership check)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const shelf = await prisma.hardcoverShelf.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!shelf) {
|
||||
return NextResponse.json({ error: 'List not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (shelf.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.hardcoverShelf.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete list', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete list' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/user/hardcover-shelves/[id]
|
||||
* Update a Hardcover shelf subscription
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const shelf = await prisma.hardcoverShelf.findUnique({ where: { id } });
|
||||
|
||||
if (!shelf) {
|
||||
return NextResponse.json({ error: 'List not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (shelf.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
|
||||
|
||||
const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
||||
let needsResync = false;
|
||||
|
||||
let cleanedToken: string | undefined;
|
||||
if (apiToken && apiToken.trim() !== '') {
|
||||
cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ')
|
||||
? apiToken.trim().slice(7).trim()
|
||||
: apiToken.trim();
|
||||
}
|
||||
|
||||
const newListId = (listId && listId !== shelf.listId) ? listId : undefined;
|
||||
|
||||
// Validate token/listId by fetching the list before saving
|
||||
if (cleanedToken || newListId) {
|
||||
const encryptionService = getEncryptionService();
|
||||
let tokenToTest = cleanedToken || shelf.apiToken;
|
||||
if (!cleanedToken) {
|
||||
try {
|
||||
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
|
||||
tokenToTest = encryptionService.decrypt(shelf.apiToken);
|
||||
}
|
||||
} catch {
|
||||
// Decryption failed, fall back to raw token
|
||||
}
|
||||
}
|
||||
const listIdToTest = newListId || shelf.listId;
|
||||
|
||||
try {
|
||||
await fetchHardcoverList(tokenToTest, listIdToTest);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidHardcoverList',
|
||||
message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (newListId) {
|
||||
updateData.listId = newListId;
|
||||
needsResync = true;
|
||||
}
|
||||
if (cleanedToken) {
|
||||
updateData.apiToken = encryptionService.encrypt(cleanedToken);
|
||||
needsResync = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we are forcing a resync due to a change, clear metadata
|
||||
if (needsResync) {
|
||||
updateData.lastSyncAt = null;
|
||||
updateData.bookCount = null;
|
||||
updateData.coverUrls = null;
|
||||
}
|
||||
|
||||
const updated = await prisma.hardcoverShelf.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
if (needsResync) {
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, shelf: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
|
||||
}
|
||||
logger.error('Failed to update list', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to update list' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Component: Hardcover Shelves API Routes
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { processBooks } from '@/lib/utils/shelf-helpers';
|
||||
|
||||
const logger = RMABLogger.create('API.HardcoverShelves');
|
||||
|
||||
const AddShelfSchema = z.object({
|
||||
listId: z.string().min(1, { message: 'List ID is required' }),
|
||||
apiToken: z.string().min(1, { message: 'API Token is required' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/hardcover-shelves
|
||||
* List the current user's Hardcover lists with book counts and covers
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const shelves = await prisma.hardcoverShelf.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const shelvesWithMeta = shelves.map((shelf) => {
|
||||
const books = processBooks(shelf.coverUrls);
|
||||
|
||||
return {
|
||||
id: shelf.id,
|
||||
name: shelf.name,
|
||||
listId: shelf.listId,
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount ?? null,
|
||||
books,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list Hardcover lists', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list Hardcover lists' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/hardcover-shelves
|
||||
* Add a new Hardcover list subscription
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
let { listId, apiToken } = AddShelfSchema.parse(body);
|
||||
|
||||
// Clean up token in case user pasted "Bearer " prefix
|
||||
apiToken = apiToken.trim();
|
||||
if (apiToken.toLowerCase().startsWith('bearer ')) {
|
||||
apiToken = apiToken.slice(7).trim();
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.hardcoverShelf.findUnique({
|
||||
where: { userId_listId: { userId: req.user.id, listId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'DuplicateShelf',
|
||||
message: 'You have already added this list',
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate by fetching the Hardcover GraphQL feed
|
||||
let listName: string;
|
||||
let bookCount: number;
|
||||
let initialBooks: {
|
||||
coverUrl: string;
|
||||
asin: null;
|
||||
title: string;
|
||||
author: string;
|
||||
}[] = [];
|
||||
try {
|
||||
const fetchedData = await fetchHardcoverList(apiToken, listId);
|
||||
listName = fetchedData.listName;
|
||||
bookCount = fetchedData.books.length;
|
||||
initialBooks = fetchedData.books
|
||||
.filter((b) => b.coverUrl)
|
||||
.slice(0, 8)
|
||||
.map((b) => ({
|
||||
coverUrl: b.coverUrl!,
|
||||
asin: null,
|
||||
title: b.title,
|
||||
author: b.author,
|
||||
}));
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidHardcoverList',
|
||||
message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedToken = encryptionService.encrypt(apiToken);
|
||||
|
||||
const shelf = await prisma.hardcoverShelf.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
name: listName,
|
||||
listId,
|
||||
apiToken: encryptedToken,
|
||||
bookCount,
|
||||
coverUrls:
|
||||
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
|
||||
logger.info(
|
||||
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
shelf: {
|
||||
id: shelf.id,
|
||||
name: shelf.name,
|
||||
listId: shelf.listId,
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount,
|
||||
books: initialBooks,
|
||||
},
|
||||
bookCount,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to add Hardcover list', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to add Hardcover list' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Component: User Home Sections API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Per-user configurable home page sections.
|
||||
* GET returns sections + next refresh time.
|
||||
* PUT saves full section config (delete-and-recreate in transaction).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.User.HomeSections');
|
||||
|
||||
const MAX_SECTIONS = 10;
|
||||
|
||||
const VALID_SECTION_TYPES = ['popular', 'new_releases', 'category'] as const;
|
||||
|
||||
const SectionSchema = z.object({
|
||||
sectionType: z.enum(VALID_SECTION_TYPES),
|
||||
categoryId: z.string().optional().nullable(),
|
||||
categoryName: z.string().optional().nullable(),
|
||||
sortOrder: z.number().int().min(0),
|
||||
});
|
||||
|
||||
const PutBodySchema = z.object({
|
||||
sections: z.array(SectionSchema).max(MAX_SECTIONS),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create default home sections for a new user (Popular + New Releases).
|
||||
*/
|
||||
async function ensureDefaultSections(userId: string) {
|
||||
const existing = await prisma.userHomeSection.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (existing.length > 0) return;
|
||||
|
||||
await prisma.userHomeSection.createMany({
|
||||
data: [
|
||||
{ userId, sectionType: 'popular', sortOrder: 0 },
|
||||
{ userId, sectionType: 'new_releases', sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/user/home-sections
|
||||
* Returns the user's configured home sections + next scheduled refresh time.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
await ensureDefaultSections(req.user.id);
|
||||
|
||||
const sections = await prisma.userHomeSection.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
// Get next refresh time from scheduled jobs
|
||||
let nextRefresh: string | null = null;
|
||||
try {
|
||||
const scheduledJob = await prisma.scheduledJob.findFirst({
|
||||
where: { type: 'audible_refresh', enabled: true },
|
||||
select: { nextRun: true },
|
||||
});
|
||||
nextRefresh = scheduledJob?.nextRun?.toISOString() || null;
|
||||
} catch {
|
||||
// Non-critical — just omit nextRefresh
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sections: sections.map((s) => ({
|
||||
id: s.id,
|
||||
sectionType: s.sectionType,
|
||||
categoryId: s.categoryId,
|
||||
categoryName: s.categoryName,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
nextRefresh,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get home sections', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch home sections' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/user/home-sections
|
||||
* Replaces all home sections for the user (delete-and-recreate in transaction).
|
||||
* Validates: max 10 sections, no duplicate sections, category sections need categoryId.
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { sections } = PutBodySchema.parse(body);
|
||||
|
||||
// Validate category sections have categoryId
|
||||
for (const section of sections) {
|
||||
if (section.sectionType === 'category' && !section.categoryId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Category sections require a categoryId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate section types (only one popular, one new_releases, unique categories)
|
||||
const seen = new Set<string>();
|
||||
for (const section of sections) {
|
||||
const key =
|
||||
section.sectionType === 'category'
|
||||
? `category:${section.categoryId}`
|
||||
: section.sectionType;
|
||||
if (seen.has(key)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: `Duplicate section: ${key}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
const userId = req.user.id;
|
||||
|
||||
// Delete-and-recreate in a transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userHomeSection.deleteMany({ where: { userId } });
|
||||
|
||||
if (sections.length > 0) {
|
||||
await tx.userHomeSection.createMany({
|
||||
data: sections.map((s, idx) => ({
|
||||
userId,
|
||||
sectionType: s.sectionType,
|
||||
categoryId: s.sectionType === 'category' ? s.categoryId : null,
|
||||
categoryName: s.sectionType === 'category' ? s.categoryName : null,
|
||||
sortOrder: idx,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Return the saved sections
|
||||
const saved = await prisma.userHomeSection.findMany({
|
||||
where: { userId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
logger.info(`User ${userId} updated home sections (${saved.length} sections)`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sections: saved.map((s) => ({
|
||||
id: s.id,
|
||||
sectionType: s.sectionType,
|
||||
categoryId: s.categoryId,
|
||||
categoryName: s.categoryName,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to save home sections', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'SaveError', message: 'Failed to save home sections' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Component: Combined Shelves API Routes
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { processBooks } from '@/lib/utils/shelf-helpers';
|
||||
|
||||
const logger = RMABLogger.create('API.Shelves');
|
||||
|
||||
/**
|
||||
* GET /api/user/shelves
|
||||
* List the current user's shelves (Goodreads, Hardcover) with book counts and covers
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const [goodreads, hardcover] = await Promise.all([
|
||||
prisma.goodreadsShelf.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.hardcoverShelf.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const combined = [
|
||||
...goodreads.map((s) => ({
|
||||
id: s.id,
|
||||
type: 'goodreads',
|
||||
name: s.name,
|
||||
sourceId: s.rssUrl,
|
||||
lastSyncAt: s.lastSyncAt,
|
||||
createdAt: s.createdAt,
|
||||
bookCount: s.bookCount ?? null,
|
||||
books: processBooks(s.coverUrls),
|
||||
})),
|
||||
...hardcover.map((s) => ({
|
||||
id: s.id,
|
||||
type: 'hardcover',
|
||||
name: s.name,
|
||||
sourceId: s.listId,
|
||||
lastSyncAt: s.lastSyncAt,
|
||||
createdAt: s.createdAt,
|
||||
bookCount: s.bookCount ?? null,
|
||||
books: processBooks(s.coverUrls),
|
||||
})),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, shelves: combined });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list shelves', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list shelves' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -300,7 +300,7 @@ export default function BookDatePage() {
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/settings')}
|
||||
onClick={() => router.push('/admin/settings')}
|
||||
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Go to Settings
|
||||
@@ -415,6 +415,7 @@ export default function BookDatePage() {
|
||||
isAvailable={currentRec.isAvailable}
|
||||
requestedByUsername={currentRec.requestedByUsername}
|
||||
hideRequestActions
|
||||
aiReason={currentRec.aiReason}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
@@ -486,6 +486,7 @@ function LoginContent() {
|
||||
quality={70}
|
||||
priority={index < 10}
|
||||
loading={index < 10 ? 'eager' : 'lazy'}
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
</div>
|
||||
|
||||
+151
-170
@@ -1,208 +1,189 @@
|
||||
/**
|
||||
* Component: Homepage - Audiobook Discovery
|
||||
* Documentation: documentation/frontend/components.md
|
||||
* Component: Homepage - Audiobook Discovery (Dynamic Sections)
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback, createRef } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
|
||||
import { HomeSection, SECTION_DOT_COLORS } from '@/components/home/HomeSection';
|
||||
import { HomeSectionConfigModal } from '@/components/home/HomeSectionConfigModal';
|
||||
import { useHomeSections } from '@/lib/hooks/useHomeSections';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
|
||||
if (sectionType === 'popular') return 'Popular Audiobooks';
|
||||
if (sectionType === 'new_releases') return 'New Releases';
|
||||
return categoryName || 'Category';
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [popularPage, setPopularPage] = useState(1);
|
||||
const [newReleasesPage, setNewReleasesPage] = useState(1);
|
||||
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
// Refs for auto-scrolling to section tops
|
||||
const popularSectionRef = useRef<HTMLElement>(null);
|
||||
const newReleasesSectionRef = useRef<HTMLElement>(null);
|
||||
// Per-section pagination state
|
||||
const [pages, setPages] = useState<Record<string, number>>({});
|
||||
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
|
||||
const footerRef = useRef<HTMLElement>(null);
|
||||
|
||||
const {
|
||||
audiobooks: popular,
|
||||
isLoading: loadingPopular,
|
||||
totalPages: popularTotalPages,
|
||||
message: popularMessage,
|
||||
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
|
||||
// Create stable refs for each section
|
||||
const sectionRefsMap = useRef<Map<string, React.RefObject<HTMLElement | null>>>(new Map());
|
||||
|
||||
const {
|
||||
audiobooks: newReleases,
|
||||
isLoading: loadingNewReleases,
|
||||
totalPages: newReleasesTotalPages,
|
||||
message: newReleasesMessage,
|
||||
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
|
||||
const getSectionKey = (s: { sectionType: string; categoryId: string | null }) =>
|
||||
s.sectionType === 'category' ? `category:${s.categoryId}` : s.sectionType;
|
||||
|
||||
// Reset to page 1 when hideAvailable changes (total pages may differ)
|
||||
// Ensure refs exist for current sections
|
||||
sections.forEach((s) => {
|
||||
const key = getSectionKey(s);
|
||||
if (!sectionRefsMap.current.has(key)) {
|
||||
sectionRefsMap.current.set(key, createRef<HTMLElement>());
|
||||
}
|
||||
});
|
||||
|
||||
// Reset pages and totalPages when hideAvailable changes
|
||||
useEffect(() => {
|
||||
setPopularPage(1);
|
||||
setNewReleasesPage(1);
|
||||
setPages({});
|
||||
setTotalPagesMap({});
|
||||
}, [hideAvailable]);
|
||||
|
||||
// Handle page changes with auto-scroll to section top
|
||||
const handlePopularPageChange = (page: number) => {
|
||||
setPopularPage(page);
|
||||
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
const getPage = (key: string) => pages[key] || 1;
|
||||
const setPage = useCallback((key: string, page: number) => {
|
||||
setPages((prev) => ({ ...prev, [key]: page }));
|
||||
}, []);
|
||||
const handleTotalPagesChange = useCallback((key: string, totalPages: number) => {
|
||||
setTotalPagesMap((prev) => {
|
||||
if (prev[key] === totalPages) return prev;
|
||||
return { ...prev, [key]: totalPages };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNewReleasesPageChange = (page: number) => {
|
||||
setNewReleasesPage(page);
|
||||
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
// Build pagination sections for the floating pill
|
||||
const paginationSections: PaginationSection[] = sections.map((s, i) => {
|
||||
const key = getSectionKey(s);
|
||||
const ref = sectionRefsMap.current.get(key)!;
|
||||
return {
|
||||
label: getSectionTitle(s.sectionType, s.categoryName),
|
||||
accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length],
|
||||
currentPage: getPage(key),
|
||||
totalPages: totalPagesMap[key] || 1,
|
||||
onPageChange: (page: number) => {
|
||||
setPage(key, page);
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
},
|
||||
sectionRef: ref,
|
||||
onScrollToSection: () =>
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
|
||||
{/* Popular Audiobooks Section */}
|
||||
<section ref={popularSectionRef} className="relative">
|
||||
{/* Sticky Section Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Popular Audiobooks
|
||||
</h2>
|
||||
<SectionToolbar
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
|
||||
{/* Loading state */}
|
||||
{sectionsLoading && (
|
||||
<div className="flex justify-center py-20">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!sectionsLoading && sections.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
No sections configured. Click Customize to add sections to your home page.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setConfigOpen(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4 mr-2" />
|
||||
Customize Home
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic sections */}
|
||||
{!sectionsLoading &&
|
||||
sections.map((section, index) => {
|
||||
const key = getSectionKey(section);
|
||||
const ref = sectionRefsMap.current.get(key)!;
|
||||
|
||||
return (
|
||||
<HomeSection
|
||||
key={key}
|
||||
sectionType={section.sectionType as 'popular' | 'new_releases' | 'category'}
|
||||
categoryId={section.categoryId}
|
||||
categoryName={section.categoryName}
|
||||
colorIndex={index}
|
||||
page={getPage(key)}
|
||||
onPageChange={(page) => {
|
||||
setPage(key, page);
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
sectionRef={ref}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
onConfigOpen={index === 0 ? () => setConfigOpen(true) : undefined}
|
||||
onTotalPagesChange={(tp) => handleTotalPagesChange(key, tp)}
|
||||
nextRefresh={nextRefresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Can't find what you're looking for?
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Use our search to find any audiobook from Audible
|
||||
</p>
|
||||
<a
|
||||
href="/search"
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
|
||||
>
|
||||
Search Audiobooks
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
|
||||
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>ReadMeABook - Audiobook Library Management System</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Section Content */}
|
||||
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
{popularMessage && !loadingPopular && popular.length === 0 ? (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
|
||||
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
|
||||
No popular audiobooks found
|
||||
</p>
|
||||
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
|
||||
{popularMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={popular}
|
||||
isLoading={loadingPopular}
|
||||
emptyMessage="No popular audiobooks available"
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{/* Unified Pagination — dynamic sections */}
|
||||
{paginationSections.length > 0 && (
|
||||
<UnifiedPagination
|
||||
footerRef={footerRef}
|
||||
sections={paginationSections}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Releases Section */}
|
||||
<section ref={newReleasesSectionRef} className="relative">
|
||||
{/* Sticky Section Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
New Releases
|
||||
</h2>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
{newReleasesMessage && !loadingNewReleases && newReleases.length === 0 ? (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
|
||||
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
|
||||
No new releases found
|
||||
</p>
|
||||
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
|
||||
{newReleasesMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={newReleases}
|
||||
isLoading={loadingNewReleases}
|
||||
emptyMessage="No new releases available"
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Can't find what you're looking for?
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Use our search to find any audiobook from Audible
|
||||
</p>
|
||||
<a
|
||||
href="/search"
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
|
||||
>
|
||||
Search Audiobooks
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
|
||||
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>ReadMeABook - Audiobook Library Management System</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Unified Pagination — single context-aware pill for both sections */}
|
||||
<UnifiedPagination
|
||||
footerRef={footerRef}
|
||||
sections={[
|
||||
{
|
||||
label: 'Popular Audiobooks',
|
||||
accentColor: 'bg-blue-500',
|
||||
currentPage: popularPage,
|
||||
totalPages: popularTotalPages,
|
||||
onPageChange: handlePopularPageChange,
|
||||
sectionRef: popularSectionRef,
|
||||
onScrollToSection: () =>
|
||||
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||
},
|
||||
{
|
||||
label: 'New Releases',
|
||||
accentColor: 'bg-emerald-500',
|
||||
currentPage: newReleasesPage,
|
||||
totalPages: newReleasesTotalPages,
|
||||
onPageChange: handleNewReleasesPageChange,
|
||||
sectionRef: newReleasesSectionRef,
|
||||
onScrollToSection: () =>
|
||||
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Config Modal */}
|
||||
<HomeSectionConfigModal
|
||||
isOpen={configOpen}
|
||||
onClose={() => setConfigOpen(false)}
|
||||
sections={sections}
|
||||
onSave={saveSections}
|
||||
/>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { RequestCard } from '@/components/requests/RequestCard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||
import { ShelvesSection } from '@/components/profile/ShelvesSection';
|
||||
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
|
||||
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
||||
|
||||
@@ -141,8 +141,8 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Goodreads Shelves */}
|
||||
<GoodreadsShelvesSection />
|
||||
{/* Generic Shelves Section */}
|
||||
<ShelvesSection />
|
||||
|
||||
{/* Watched Series */}
|
||||
<WatchedSeriesSection />
|
||||
|
||||
@@ -46,6 +46,8 @@ const getStatusConfig = (audiobook: Audiobook) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
|
||||
|
||||
export function AudiobookCard({
|
||||
audiobook,
|
||||
onRequestSuccess,
|
||||
@@ -57,6 +59,7 @@ export function AudiobookCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
|
||||
// Build a display-only audiobook with the local status override
|
||||
const displayAudiobook = localRequestStatus !== undefined
|
||||
@@ -113,20 +116,23 @@ export function AudiobookCard({
|
||||
`}
|
||||
>
|
||||
{/* Cover Art */}
|
||||
{audiobook.coverArtUrl ? (
|
||||
{audiobook.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={audiobook.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src={PLACEHOLDER_COVER}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover Overlay with Actions - Desktop Only
|
||||
|
||||
@@ -34,6 +34,7 @@ interface AudiobookDetailsModalProps {
|
||||
requestedByUsername?: string | null;
|
||||
hideRequestActions?: boolean;
|
||||
hasReportedIssue?: boolean;
|
||||
aiReason?: string | null;
|
||||
}
|
||||
|
||||
// Status helper
|
||||
@@ -74,6 +75,7 @@ export function AudiobookDetailsModal({
|
||||
requestedByUsername = null,
|
||||
hideRequestActions = false,
|
||||
hasReportedIssue = false,
|
||||
aiReason = null,
|
||||
}: AudiobookDetailsModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
@@ -94,6 +96,7 @@ export function AudiobookDetailsModal({
|
||||
const [asinCopied, setAsinCopied] = useState(false);
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
|
||||
// Sync local status when the prop changes (e.g. page data refreshes)
|
||||
useEffect(() => {
|
||||
@@ -285,7 +288,7 @@ export function AudiobookDetailsModal({
|
||||
${squareCovers ? 'w-40 sm:w-44 lg:w-52 aspect-square' : 'w-32 sm:w-40 lg:w-48 aspect-[2/3]'}
|
||||
${status.type === 'available' ? 'ring-2 ring-emerald-400/60' : ''}
|
||||
`}>
|
||||
{audiobook.coverArtUrl ? (
|
||||
{audiobook.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={audiobook.coverArtUrl}
|
||||
alt=""
|
||||
@@ -293,13 +296,16 @@ export function AudiobookDetailsModal({
|
||||
className="object-cover"
|
||||
sizes="200px"
|
||||
priority
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src="/placeholder_cover.svg"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="200px"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rating Badge */}
|
||||
@@ -455,6 +461,20 @@ export function AudiobookDetailsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Recommendation Reasoning */}
|
||||
{aiReason && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Why This Was Recommended
|
||||
</h3>
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
|
||||
{aiReason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
|
||||
@@ -250,10 +250,12 @@ export function BookPickerModal({
|
||||
{/* Cover Image or Text Placeholder */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-700 dark:to-gray-600">
|
||||
{book.coverUrl ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
alt={book.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-3">
|
||||
|
||||
@@ -27,6 +27,7 @@ export function RecommendationCard({
|
||||
isDraggable = true,
|
||||
}: RecommendationCardProps) {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
@@ -227,7 +228,7 @@ export function RecommendationCard({
|
||||
|
||||
{/* Cover image - smaller on mobile to fit all content */}
|
||||
<div className="w-full relative bg-gray-200 dark:bg-gray-700 flex-shrink-0" style={{ maxHeight: 'min(25vh, 300px)' }}>
|
||||
{recommendation.coverUrl ? (
|
||||
{recommendation.coverUrl && !coverError ? (
|
||||
<Image
|
||||
src={recommendation.coverUrl}
|
||||
alt={recommendation.title}
|
||||
@@ -236,11 +237,17 @@ export function RecommendationCard({
|
||||
className="object-contain w-full h-auto"
|
||||
style={{ maxHeight: 'min(25vh, 300px)' }}
|
||||
unoptimized
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-48 flex items-center justify-center">
|
||||
<span className="text-6xl">📚</span>
|
||||
</div>
|
||||
<Image
|
||||
src="/placeholder_cover.svg"
|
||||
alt={recommendation.title}
|
||||
width={400}
|
||||
height={400}
|
||||
className="object-contain w-full h-auto"
|
||||
style={{ maxHeight: 'min(25vh, 300px)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Component: Home Section — renders a single audiobook discovery section
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Handles popular, new_releases, and category section types with unified rendering.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||
import { useCategoryAudiobooks } from '@/lib/hooks/useHomeSections';
|
||||
import { Cog6ToothIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const SECTION_COLORS = [
|
||||
'from-blue-500 to-indigo-500',
|
||||
'from-emerald-500 to-teal-500',
|
||||
'from-violet-500 to-purple-500',
|
||||
'from-amber-500 to-orange-500',
|
||||
'from-rose-500 to-pink-500',
|
||||
'from-cyan-500 to-sky-500',
|
||||
'from-fuchsia-500 to-pink-500',
|
||||
'from-lime-500 to-green-500',
|
||||
'from-orange-500 to-red-500',
|
||||
'from-teal-500 to-emerald-500',
|
||||
];
|
||||
|
||||
export const SECTION_DOT_COLORS = [
|
||||
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
|
||||
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
|
||||
];
|
||||
|
||||
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
|
||||
if (sectionType === 'popular') return 'Popular Audiobooks';
|
||||
if (sectionType === 'new_releases') return 'New Releases';
|
||||
return categoryName || 'Category';
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a nextRefresh ISO timestamp into a friendly, readable string.
|
||||
* Examples: "today at 6:00 PM", "tomorrow at 2:00 AM", "Saturday at 9:00 AM"
|
||||
*/
|
||||
function formatNextRefresh(isoString: string): string {
|
||||
const refreshDate = new Date(isoString);
|
||||
const now = new Date();
|
||||
|
||||
const refreshMidnight = new Date(refreshDate);
|
||||
refreshMidnight.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayMidnight = new Date(now);
|
||||
todayMidnight.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrowMidnight = new Date(todayMidnight);
|
||||
tomorrowMidnight.setDate(tomorrowMidnight.getDate() + 1);
|
||||
|
||||
const dayAfterMidnight = new Date(tomorrowMidnight);
|
||||
dayAfterMidnight.setDate(dayAfterMidnight.getDate() + 1);
|
||||
|
||||
const timeStr = refreshDate.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
if (refreshMidnight.getTime() === todayMidnight.getTime()) {
|
||||
return `today at ${timeStr}`;
|
||||
}
|
||||
if (refreshMidnight.getTime() === tomorrowMidnight.getTime()) {
|
||||
return `tomorrow at ${timeStr}`;
|
||||
}
|
||||
if (refreshMidnight.getTime() < dayAfterMidnight.getTime()) {
|
||||
const dayName = refreshDate.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
return `${dayName} at ${timeStr}`;
|
||||
}
|
||||
|
||||
const dateStr = refreshDate.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
}
|
||||
|
||||
interface HomeSectionProps {
|
||||
sectionType: 'popular' | 'new_releases' | 'category';
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
colorIndex: number;
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
sectionRef: React.RefObject<HTMLElement | null>;
|
||||
cardSize: number;
|
||||
squareCovers: boolean;
|
||||
hideAvailable: boolean;
|
||||
onToggleHideAvailable: (v: boolean) => void;
|
||||
onToggleSquareCovers: (v: boolean) => void;
|
||||
onCardSizeChange: (v: number) => void;
|
||||
onConfigOpen?: () => void;
|
||||
onTotalPagesChange?: (totalPages: number) => void;
|
||||
nextRefresh: string | null;
|
||||
}
|
||||
|
||||
function PopularOrNewSection({
|
||||
type,
|
||||
page,
|
||||
hideAvailable,
|
||||
onTotalPagesChange,
|
||||
...renderProps
|
||||
}: {
|
||||
type: 'popular' | 'new-releases';
|
||||
page: number;
|
||||
hideAvailable: boolean;
|
||||
onTotalPagesChange?: (totalPages: number) => void;
|
||||
} & RenderSectionProps) {
|
||||
const { audiobooks, isLoading, totalPages, message } = useAudiobooks(type, 20, page, hideAvailable);
|
||||
|
||||
useEffect(() => {
|
||||
onTotalPagesChange?.(totalPages);
|
||||
}, [totalPages, onTotalPagesChange]);
|
||||
|
||||
return (
|
||||
<RenderSection
|
||||
audiobooks={audiobooks}
|
||||
isLoading={isLoading}
|
||||
totalPages={totalPages}
|
||||
message={message}
|
||||
{...renderProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
categoryId,
|
||||
page,
|
||||
hideAvailable,
|
||||
onTotalPagesChange,
|
||||
...renderProps
|
||||
}: {
|
||||
categoryId: string;
|
||||
page: number;
|
||||
hideAvailable: boolean;
|
||||
onTotalPagesChange?: (totalPages: number) => void;
|
||||
} & RenderSectionProps) {
|
||||
const { audiobooks, isLoading, totalPages, message } = useCategoryAudiobooks(
|
||||
categoryId,
|
||||
20,
|
||||
page,
|
||||
hideAvailable
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onTotalPagesChange?.(totalPages);
|
||||
}, [totalPages, onTotalPagesChange]);
|
||||
|
||||
return (
|
||||
<RenderSection
|
||||
audiobooks={audiobooks}
|
||||
isLoading={isLoading}
|
||||
totalPages={totalPages}
|
||||
message={message}
|
||||
{...renderProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderSectionProps {
|
||||
cardSize: number;
|
||||
squareCovers: boolean;
|
||||
nextRefresh?: string | null;
|
||||
}
|
||||
|
||||
function CategoryEmptyState({ nextRefresh }: { nextRefresh?: string | null }) {
|
||||
const refreshLabel = nextRefresh ? formatNextRefresh(nextRefresh) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-14 px-6 text-center">
|
||||
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-gray-100 dark:bg-gray-700/60 mb-4">
|
||||
<ClockIcon className="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
No audiobooks yet
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs leading-relaxed">
|
||||
{refreshLabel
|
||||
? <>This section will fill in after the next data refresh, scheduled for <span className="text-gray-500 dark:text-gray-400">{refreshLabel}</span>.</>
|
||||
: 'This section will fill in after the next scheduled data refresh.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSection({
|
||||
audiobooks,
|
||||
isLoading,
|
||||
totalPages,
|
||||
message,
|
||||
cardSize,
|
||||
squareCovers,
|
||||
nextRefresh,
|
||||
}: RenderSectionProps & {
|
||||
audiobooks: any[];
|
||||
isLoading: boolean;
|
||||
totalPages: number;
|
||||
message: string | null;
|
||||
}) {
|
||||
if (message && !isLoading && audiobooks.length === 0) {
|
||||
return <CategoryEmptyState nextRefresh={nextRefresh} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AudiobookGrid
|
||||
audiobooks={audiobooks}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No audiobooks available"
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeSection({
|
||||
sectionType,
|
||||
categoryId,
|
||||
categoryName,
|
||||
colorIndex,
|
||||
page,
|
||||
onPageChange,
|
||||
sectionRef,
|
||||
cardSize,
|
||||
squareCovers,
|
||||
hideAvailable,
|
||||
onToggleHideAvailable,
|
||||
onToggleSquareCovers,
|
||||
onCardSizeChange,
|
||||
onConfigOpen,
|
||||
onTotalPagesChange,
|
||||
nextRefresh,
|
||||
}: HomeSectionProps) {
|
||||
const gradient = SECTION_COLORS[colorIndex % SECTION_COLORS.length];
|
||||
const title = getSectionTitle(sectionType, categoryName);
|
||||
|
||||
const renderProps: RenderSectionProps = { cardSize, squareCovers, nextRefresh };
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="relative">
|
||||
{/* Sticky Section Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-1 h-6 bg-gradient-to-b ${gradient} rounded-full`} />
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{title}
|
||||
</h2>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={onToggleHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={onToggleSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={onCardSizeChange}
|
||||
/>
|
||||
{onConfigOpen && (
|
||||
<button
|
||||
onClick={onConfigOpen}
|
||||
className="p-1.5 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
aria-label="Customize home page"
|
||||
title="Customize sections"
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
{sectionType === 'popular' && (
|
||||
<PopularOrNewSection
|
||||
type="popular"
|
||||
page={page}
|
||||
hideAvailable={hideAvailable}
|
||||
onTotalPagesChange={onTotalPagesChange}
|
||||
{...renderProps}
|
||||
/>
|
||||
)}
|
||||
{sectionType === 'new_releases' && (
|
||||
<PopularOrNewSection
|
||||
type="new-releases"
|
||||
page={page}
|
||||
hideAvailable={hideAvailable}
|
||||
onTotalPagesChange={onTotalPagesChange}
|
||||
{...renderProps}
|
||||
/>
|
||||
)}
|
||||
{sectionType === 'category' && categoryId && (
|
||||
<CategorySection
|
||||
categoryId={categoryId}
|
||||
page={page}
|
||||
hideAvailable={hideAvailable}
|
||||
onTotalPagesChange={onTotalPagesChange}
|
||||
{...renderProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Component: Home Section Configuration Modal
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Allows users to add/remove/reorder home page sections.
|
||||
* Drag-and-drop on desktop, up/down arrows on mobile. Auto-save with debounce.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
Bars3Icon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { HomeSection, AudibleCategory } from '@/lib/hooks/useHomeSections';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
|
||||
const MAX_SECTIONS = 10;
|
||||
|
||||
const SECTION_COLORS = [
|
||||
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
|
||||
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
|
||||
];
|
||||
|
||||
function getSectionLabel(section: { sectionType: string; categoryName?: string | null }) {
|
||||
if (section.sectionType === 'popular') return 'Popular Audiobooks';
|
||||
if (section.sectionType === 'new_releases') return 'New Releases';
|
||||
return section.categoryName || 'Category';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sections: HomeSection[];
|
||||
onSave: (sections: Omit<HomeSection, 'id'>[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function HomeSectionConfigModal({ isOpen, onClose, sections, onSave }: Props) {
|
||||
const [localSections, setLocalSections] = useState<Omit<HomeSection, 'id'>[]>([]);
|
||||
const [categories, setCategories] = useState<AudibleCategory[]>([]);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
|
||||
// Sync from prop when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalSections(
|
||||
sections.map((s) => ({
|
||||
sectionType: s.sectionType,
|
||||
categoryId: s.categoryId,
|
||||
categoryName: s.categoryName,
|
||||
sortOrder: s.sortOrder,
|
||||
}))
|
||||
);
|
||||
setDirty(false);
|
||||
setShowCategoryPicker(false);
|
||||
}
|
||||
}, [isOpen, sections]);
|
||||
|
||||
// Auto-save with debounce
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(localSections.map((s, i) => ({ ...s, sortOrder: i })));
|
||||
} catch {
|
||||
// Silently fail — user will see stale state
|
||||
}
|
||||
setSaving(false);
|
||||
setDirty(false);
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [dirty, localSections, onSave]);
|
||||
|
||||
// Fetch categories when picker opens
|
||||
const loadCategories = useCallback(async () => {
|
||||
if (categories.length > 0) {
|
||||
setShowCategoryPicker(true);
|
||||
return;
|
||||
}
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const data = await authenticatedFetcher('/api/audible/categories');
|
||||
setCategories(data.categories || []);
|
||||
} catch {
|
||||
setCategories([]);
|
||||
}
|
||||
setLoadingCategories(false);
|
||||
setShowCategoryPicker(true);
|
||||
}, [categories.length]);
|
||||
|
||||
const addCategory = useCallback(
|
||||
(cat: AudibleCategory) => {
|
||||
if (localSections.length >= MAX_SECTIONS) return;
|
||||
// Prevent duplicate
|
||||
if (localSections.some((s) => s.sectionType === 'category' && s.categoryId === cat.id)) return;
|
||||
|
||||
setLocalSections((prev) => [
|
||||
...prev,
|
||||
{
|
||||
sectionType: 'category' as const,
|
||||
categoryId: cat.id,
|
||||
categoryName: cat.name,
|
||||
sortOrder: prev.length,
|
||||
},
|
||||
]);
|
||||
setDirty(true);
|
||||
setShowCategoryPicker(false);
|
||||
},
|
||||
[localSections]
|
||||
);
|
||||
|
||||
const addBuiltIn = useCallback(
|
||||
(type: 'popular' | 'new_releases') => {
|
||||
if (localSections.length >= MAX_SECTIONS) return;
|
||||
if (localSections.some((s) => s.sectionType === type)) return;
|
||||
|
||||
setLocalSections((prev) => [
|
||||
...prev,
|
||||
{ sectionType: type, categoryId: null, categoryName: null, sortOrder: prev.length },
|
||||
]);
|
||||
setDirty(true);
|
||||
},
|
||||
[localSections]
|
||||
);
|
||||
|
||||
const removeSection = useCallback((index: number) => {
|
||||
setLocalSections((prev) => prev.filter((_, i) => i !== index));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const moveSection = useCallback((from: number, to: number) => {
|
||||
setLocalSections((prev) => {
|
||||
const next = [...prev];
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = (index: number) => {
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex === null || dragIndex === index) return;
|
||||
moveSection(dragIndex, index);
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDragIndex(null);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hasPopular = localSections.some((s) => s.sectionType === 'popular');
|
||||
const hasNewReleases = localSections.some((s) => s.sectionType === 'new_releases');
|
||||
const existingCategoryIds = new Set(
|
||||
localSections.filter((s) => s.sectionType === 'category').map((s) => s.categoryId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Customize Home
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{localSections.length}/{MAX_SECTIONS} sections
|
||||
{saving && (
|
||||
<span className="ml-2 text-blue-500 dark:text-blue-400">Saving...</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section list */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
|
||||
{localSections.length === 0 && (
|
||||
<div className="text-center text-gray-400 dark:text-gray-500 py-8">
|
||||
<p className="text-sm">No sections configured.</p>
|
||||
<p className="text-xs mt-1">Add sections below to customize your home page.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localSections.map((section, index) => (
|
||||
<div
|
||||
key={`${section.sectionType}-${section.categoryId || index}`}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-200
|
||||
${dragIndex === index
|
||||
? 'border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md scale-[1.02]'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hidden sm:block">
|
||||
<Bars3Icon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Color dot */}
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${SECTION_COLORS[index % SECTION_COLORS.length]}`} />
|
||||
|
||||
{/* Label */}
|
||||
<span className="flex-1 text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
|
||||
{getSectionLabel(section)}
|
||||
</span>
|
||||
|
||||
{/* Mobile reorder arrows */}
|
||||
<div className="flex sm:hidden gap-0.5">
|
||||
<button
|
||||
onClick={() => index > 0 && moveSection(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUpIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => index < localSections.length - 1 && moveSection(index, index + 1)}
|
||||
disabled={index === localSections.length - 1}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
onClick={() => removeSection(index)}
|
||||
className="p-1 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
aria-label={`Remove ${getSectionLabel(section)}`}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add section controls */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
{/* Built-in section buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{!hasPopular && (
|
||||
<button
|
||||
onClick={() => addBuiltIn('popular')}
|
||||
disabled={localSections.length >= MAX_SECTIONS}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
Popular
|
||||
</button>
|
||||
)}
|
||||
{!hasNewReleases && (
|
||||
<button
|
||||
onClick={() => addBuiltIn('new_releases')}
|
||||
disabled={localSections.length >= MAX_SECTIONS}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
New Releases
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={loadCategories}
|
||||
disabled={localSections.length >= MAX_SECTIONS || loadingCategories}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-900/20 rounded-lg hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
{loadingCategories ? 'Loading...' : 'Category'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category picker */}
|
||||
{showCategoryPicker && (
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
{categories.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500">No categories found.</div>
|
||||
) : (
|
||||
categories
|
||||
.filter((c) => !existingCategoryIds.has(c.id))
|
||||
.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => addCategory(cat)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-100 dark:border-gray-700/50 last:border-0"
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowCategoryPicker(false)}
|
||||
className="w-full px-4 py-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { VersionBadge } from '@/components/ui/VersionBadge';
|
||||
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
|
||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export function Header() {
|
||||
@@ -21,8 +20,8 @@ export function Header() {
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
const [showBookDate, setShowBookDate] = useState(false);
|
||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||
const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
|
||||
const { containerRef, dropdownRef, positionAbove, style } =
|
||||
useSmartDropdownPosition(showUserMenu);
|
||||
|
||||
// Check if user can change password (local users only)
|
||||
const canChangePassword = user?.authProvider === 'local';
|
||||
@@ -44,16 +43,14 @@ export function Header() {
|
||||
|
||||
const response = await fetch('/api/bookdate/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// Show BookDate to any user with verified and enabled configuration
|
||||
setShowBookDate(
|
||||
data.config &&
|
||||
data.config.isVerified &&
|
||||
data.config.isEnabled
|
||||
data.config && data.config.isVerified && data.config.isEnabled,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to check BookDate config:', error);
|
||||
@@ -92,15 +89,6 @@ export function Header() {
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowAddGoodreadsModal(true);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Add Goodreads Shelf
|
||||
</button>
|
||||
{canChangePassword && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -206,8 +194,18 @@ export function Header() {
|
||||
className="md:hidden p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
@@ -218,12 +216,32 @@ export function Header() {
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{showMobileMenu ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
@@ -327,19 +345,15 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
{/* User menu dropdown (rendered via portal) */}
|
||||
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)}
|
||||
{typeof window !== 'undefined' &&
|
||||
userMenuDropdown &&
|
||||
createPortal(userMenuDropdown, document.body)}
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePasswordModal}
|
||||
onClose={() => setShowChangePasswordModal(false)}
|
||||
/>
|
||||
|
||||
{/* Add Goodreads Shelf Modal */}
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddGoodreadsModal}
|
||||
onClose={() => setShowAddGoodreadsModal(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
+194
-58
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Component: Goodreads Shelves Section (Profile Page)
|
||||
* Component: Combined Shelves Section (Profile Page)
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
||||
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ManageShelfModal } from '@/components/ui/ManageShelfModal';
|
||||
import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
@@ -26,54 +31,88 @@ function formatRelativeTime(dateStr: string | null): string {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function GoodreadsShelvesSection() {
|
||||
const { shelves, isLoading } = useGoodreadsShelves();
|
||||
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf();
|
||||
export function ShelvesSection() {
|
||||
const { shelves, isLoading } = useShelves();
|
||||
const { deleteShelf: deleteGoodreads, isLoading: isDeletingGoodreads } =
|
||||
useDeleteGoodreadsShelf();
|
||||
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
||||
useDeleteHardcoverShelf();
|
||||
const { squareCovers } = usePreferences();
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (shelfId: string) => {
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [showAddShelf, setShowAddShelf] = useState(false);
|
||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||
const [manageShelf, setManageShelf] = useState<GenericShelf | null>(null);
|
||||
|
||||
const handleDelete = async (shelf: GenericShelf) => {
|
||||
try {
|
||||
await deleteShelf(shelfId);
|
||||
if (shelf.type === 'goodreads') {
|
||||
await deleteGoodreads(shelf.id);
|
||||
} else {
|
||||
await deleteHardcover(shelf.id);
|
||||
}
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10">
|
||||
<svg className="w-[18px] h-[18px] text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-500/10 dark:to-teal-500/10 flex items-center justify-center ring-1 ring-emerald-200/50 dark:ring-emerald-500/10">
|
||||
<svg
|
||||
className="w-[18px] h-[18px] text-emerald-600 dark:text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
|
||||
Goodreads Shelves
|
||||
Shelves
|
||||
</h2>
|
||||
{!isLoading && shelves.length > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected
|
||||
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'}{' '}
|
||||
connected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
{shelves.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAddShelf(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -88,23 +127,30 @@ export function GoodreadsShelvesSection() {
|
||||
squareCovers={squareCovers}
|
||||
isDeleting={isDeleting && confirmDeleteId === shelf.id}
|
||||
isConfirmingDelete={confirmDeleteId === shelf.id}
|
||||
onDelete={() => handleDelete(shelf.id)}
|
||||
onDelete={() => handleDelete(shelf)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onManage={() => setManageShelf(shelf)}
|
||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState onAdd={() => setShowAddModal(true)} />
|
||||
<EmptyState onAdd={() => setShowAddShelf(true)} />
|
||||
)}
|
||||
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
{/* Modals */}
|
||||
<AddShelfModal
|
||||
isOpen={showAddShelf}
|
||||
onClose={() => setShowAddShelf(false)}
|
||||
/>
|
||||
|
||||
<ManageShelfModal
|
||||
isOpen={!!manageShelf}
|
||||
onClose={() => setManageShelf(null)}
|
||||
shelf={manageShelf}
|
||||
/>
|
||||
|
||||
{/* Audiobook Detail Modal (read-only) */}
|
||||
{selectedAsin && (
|
||||
<AudiobookDetailsModal
|
||||
asin={selectedAsin}
|
||||
@@ -122,9 +168,19 @@ export function GoodreadsShelvesSection() {
|
||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700/40 p-10 sm:p-14 text-center">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center mb-5 ring-1 ring-amber-200/50 dark:ring-amber-500/10">
|
||||
<svg className="w-7 h-7 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-500/10 dark:to-teal-500/10 flex items-center justify-center mb-5 ring-1 ring-emerald-200/50 dark:ring-emerald-500/10">
|
||||
<svg
|
||||
className="w-7 h-7 text-emerald-500 dark:text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -132,15 +188,26 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
Connect your reading list
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
|
||||
Link a Goodreads shelf and we'll automatically request the audiobook for every book you add.
|
||||
Link a Goodreads or Hardcover shelf and we'll automatically request the
|
||||
audiobook for every book you add.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Your First Shelf
|
||||
</button>
|
||||
@@ -166,7 +233,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]',
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
|
||||
/>
|
||||
@@ -179,13 +246,14 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
/* ─── Shelf Card ─── */
|
||||
|
||||
interface ShelfCardProps {
|
||||
shelf: GoodreadsShelf;
|
||||
shelf: GenericShelf;
|
||||
squareCovers: boolean;
|
||||
isDeleting: boolean;
|
||||
isConfirmingDelete: boolean;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onManage: () => void;
|
||||
onBookClick: (asin: string) => void;
|
||||
}
|
||||
|
||||
@@ -197,20 +265,44 @@ function ShelfCard({
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onManage,
|
||||
onBookClick,
|
||||
}: ShelfCardProps) {
|
||||
const displayBooks = shelf.books.slice(0, 6);
|
||||
const hasCovers = displayBooks.length > 0;
|
||||
const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length);
|
||||
const remainingCount = Math.max(
|
||||
0,
|
||||
(shelf.bookCount || 0) - displayBooks.length,
|
||||
);
|
||||
const isSyncing = !shelf.lastSyncAt;
|
||||
|
||||
const providerIcon =
|
||||
shelf.type === 'goodreads' ? (
|
||||
<img
|
||||
src="/goodreads-icon.png"
|
||||
alt="Goodreads"
|
||||
className="w-5 h-5 ml-2 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/hardcover-icon.svg"
|
||||
alt="Hardcover"
|
||||
className="w-5 h-5 ml-2 object-contain"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
|
||||
{/* Top: Shelf info + actions */}
|
||||
<div className={cn('flex items-start justify-between', (hasCovers || isSyncing) && 'mb-5')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between',
|
||||
(hasCovers || isSyncing) && 'mb-5',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
|
||||
{shelf.name}
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
|
||||
{shelf.name} {providerIcon}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{shelf.bookCount != null && (
|
||||
@@ -259,22 +351,60 @@ function ShelfCard({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Remove shelf"
|
||||
>
|
||||
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onManage}
|
||||
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
|
||||
title="Manage shelf"
|
||||
aria-label="Manage shelf"
|
||||
>
|
||||
<svg
|
||||
className="w-[18px] h-[18px]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
|
||||
title="Remove shelf"
|
||||
aria-label="Remove shelf"
|
||||
>
|
||||
<svg
|
||||
className="w-[18px] h-[18px]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Stacked book covers */}
|
||||
{hasCovers ? (
|
||||
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
|
||||
<CoverStack
|
||||
books={displayBooks}
|
||||
remainingCount={remainingCount}
|
||||
squareCovers={squareCovers}
|
||||
onBookClick={onBookClick}
|
||||
/>
|
||||
) : isSyncing ? (
|
||||
<div className="flex items-end">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
@@ -282,7 +412,7 @@ function ShelfCard({
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]',
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
|
||||
/>
|
||||
@@ -322,7 +452,7 @@ function CoverStack({
|
||||
'transition-all duration-300 ease-out',
|
||||
hoveredIndex === i && 'scale-[1.18] shadow-xl',
|
||||
coverSize,
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default'
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
style={{
|
||||
marginLeft: i > 0 ? '-16px' : 0,
|
||||
@@ -331,14 +461,20 @@ function CoverStack({
|
||||
onMouseEnter={() => setHoveredIndex(i)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => book.asin && onBookClick(book.asin)}
|
||||
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
|
||||
title={
|
||||
book.asin
|
||||
? `${book.title}${book.author ? ` by ${book.author}` : ''}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
src={book.coverUrl || '/placeholder_cover.svg'}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -346,7 +482,7 @@ function CoverStack({
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
coverSize
|
||||
coverSize,
|
||||
)}
|
||||
style={{ marginLeft: '-16px', zIndex: 0 }}
|
||||
>
|
||||
@@ -101,15 +101,14 @@ function WatchedSeriesCard({
|
||||
{/* Cover */}
|
||||
<button onClick={onNavigate} className="flex-shrink-0">
|
||||
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
|
||||
{item.coverArtUrl ? (
|
||||
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
src={item.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={item.seriesTitle}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -9,11 +9,9 @@ import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCancelRequest, useManualSearch } from '@/lib/hooks/useRequests';
|
||||
import { useCancelRequest } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
|
||||
@@ -43,12 +41,10 @@ interface RequestCardProps {
|
||||
|
||||
export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const { cancelRequest, isLoading } = useCancelRequest();
|
||||
const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch();
|
||||
const { squareCovers } = usePreferences();
|
||||
const { user } = useAuth();
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||
const [coverError, setCoverError] = React.useState(false);
|
||||
|
||||
const requestType = request.type || 'audiobook';
|
||||
const isEbook = requestType === 'ebook';
|
||||
@@ -57,10 +53,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
// Ebook requests don't support interactive search (Anna's Archive only)
|
||||
// Interactive search also requires the interactiveSearch permission
|
||||
const hasInteractiveSearchAccess = user?.role === 'admin' || user?.permissions?.interactiveSearch !== false;
|
||||
const canSearch = hasInteractiveSearchAccess && !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
||||
@@ -72,20 +64,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualSearch = async () => {
|
||||
try {
|
||||
await triggerManualSearch(request.id);
|
||||
// Request list will auto-refresh via SWR
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger manual search:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to trigger manual search');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setShowInteractiveSearch(true);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
@@ -121,41 +99,34 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
|
||||
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
|
||||
>
|
||||
{request.audiobook.coverArtUrl ? (
|
||||
{request.audiobook.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={request.audiobook.coverArtUrl}
|
||||
alt={request.audiobook.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
) : isEbook ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{isEbook ? (
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src="/placeholder_cover.svg"
|
||||
alt={request.audiobook.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,27 +226,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
{/* Action Buttons */}
|
||||
{showActions && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canSearch && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleManualSearch}
|
||||
loading={isManualSearching}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Manual Search
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInteractiveSearch}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Interactive Search
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
@@ -293,17 +243,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Modal */}
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearch}
|
||||
onClose={() => setShowInteractiveSearch(false)}
|
||||
requestId={request.id}
|
||||
audiobook={{
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Audiobook Details Modal */}
|
||||
{request.audiobook.audibleAsin && (
|
||||
<AudiobookDetailsModal
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SeriesSummary } from '@/lib/hooks/useSeries';
|
||||
@@ -20,6 +20,7 @@ interface SeriesCardProps {
|
||||
}
|
||||
|
||||
export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
const visibleTags = series.tags.slice(0, 2);
|
||||
const hasTags = visibleTags.length > 0;
|
||||
const hasRating = series.rating != null && series.rating > 0;
|
||||
@@ -42,30 +43,23 @@ export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
|
||||
`}
|
||||
>
|
||||
{/* Cover Art or Fallback */}
|
||||
{series.coverArtUrl ? (
|
||||
{series.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={series.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600 to-teal-800 dark:from-emerald-700 dark:to-teal-900 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-1/3 h-1/3 text-white/40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src="/placeholder_cover.svg"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top-row badges — Rating (left) + Book count (right) */}
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
||||
import { WatchSeriesButton } from '@/components/ui/WatchButton';
|
||||
|
||||
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
|
||||
|
||||
interface SeriesDetailCardProps {
|
||||
series: SeriesDetail;
|
||||
squareCovers?: boolean;
|
||||
@@ -20,6 +22,7 @@ interface SeriesDetailCardProps {
|
||||
|
||||
export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
const hasLongDescription = (series.description?.length || 0) > 300;
|
||||
|
||||
return (
|
||||
@@ -27,7 +30,7 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
|
||||
{/* Rectangular Cover */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`relative w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40`}>
|
||||
{series.books[0]?.coverArtUrl ? (
|
||||
{series.books[0]?.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={series.books[0].coverArtUrl}
|
||||
alt={series.title}
|
||||
@@ -35,13 +38,16 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
|
||||
priority
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
|
||||
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src={PLACEHOLDER_COVER}
|
||||
alt={series.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,21 +97,14 @@ export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = fa
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className={`relative w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300`}>
|
||||
{s.coverArtUrl ? (
|
||||
<Image
|
||||
src={s.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-emerald-400 dark:text-emerald-300">
|
||||
{s.title.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
src={s.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Component: Add Goodreads Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
|
||||
interface AddGoodreadsShelfModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||
|
||||
export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) {
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const { addShelf, isLoading, error } = useAddGoodreadsShelf();
|
||||
|
||||
const validateUrl = (url: string): boolean => {
|
||||
if (!url.trim()) {
|
||||
setValidationError('RSS URL is required');
|
||||
return false;
|
||||
}
|
||||
if (!GOODREADS_RSS_PATTERN.test(url)) {
|
||||
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
|
||||
return false;
|
||||
}
|
||||
setValidationError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateUrl(rssUrl)) return;
|
||||
|
||||
try {
|
||||
const shelf = await addShelf(rssUrl);
|
||||
setSuccess(true);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRssUrl('');
|
||||
setValidationError('');
|
||||
setSuccess(false);
|
||||
setSuccessMessage('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Goodreads Shelf" size="sm">
|
||||
<div className="space-y-5">
|
||||
{/* Visual header */}
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.5 8.257a4.5 4.5 0 007.244 1.242" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success alert */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<Input
|
||||
type="url"
|
||||
label="Goodreads RSS URL"
|
||||
value={rssUrl}
|
||||
onChange={(e) => {
|
||||
setRssUrl(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
error={validationError}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
||||
Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isLoading}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Add Shelf
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Component: Add Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { HardcoverForm } from './HardcoverForm';
|
||||
|
||||
interface AddShelfModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||
|
||||
export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
||||
const [provider, setProvider] = useState<'goodreads' | 'hardcover'>('goodreads');
|
||||
|
||||
// Goodreads State
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
|
||||
// Hardcover State
|
||||
const [apiToken, setApiToken] = useState('');
|
||||
const [listType, setListType] = useState<'status' | 'custom'>('status');
|
||||
const [statusId, setStatusId] = useState('1');
|
||||
const [customListId, setCustomListId] = useState('');
|
||||
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
const { addShelf: addGoodreads, isLoading: isGoodreadsLoading, error: goodreadsError } = useAddGoodreadsShelf();
|
||||
const { addShelf: addHardcover, isLoading: isHardcoverLoading, error: hardcoverError } = useAddHardcoverShelf();
|
||||
|
||||
const isLoading = isGoodreadsLoading || isHardcoverLoading;
|
||||
const currentError = provider === 'goodreads' ? goodreadsError : hardcoverError;
|
||||
|
||||
const validateInput = (): boolean => {
|
||||
if (provider === 'goodreads') {
|
||||
if (!rssUrl.trim()) {
|
||||
setValidationError('RSS URL is required');
|
||||
return false;
|
||||
}
|
||||
if (!GOODREADS_RSS_PATTERN.test(rssUrl)) {
|
||||
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!apiToken.trim()) {
|
||||
setValidationError('Hardcover API Token is required');
|
||||
return false;
|
||||
}
|
||||
if (listType === 'custom' && !customListId.trim()) {
|
||||
setValidationError('Hardcover List URL or Slug is required');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setValidationError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validateInput()) return;
|
||||
|
||||
try {
|
||||
if (provider === 'goodreads') {
|
||||
const shelf = await addGoodreads(rssUrl);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
} else {
|
||||
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
|
||||
const shelf = await addHardcover(apiToken.trim(), finalId);
|
||||
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
|
||||
setApiToken('');
|
||||
setCustomListId('');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Error is handled by the hooks
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRssUrl('');
|
||||
setApiToken('');
|
||||
setCustomListId('');
|
||||
setValidationError('');
|
||||
setSuccess(false);
|
||||
setSuccessMessage('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Shelf" size="sm">
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Provider Tabs */}
|
||||
<div className="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${
|
||||
provider === 'goodreads'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
onClick={() => { setProvider('goodreads'); setValidationError(''); }}
|
||||
>
|
||||
Goodreads
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${
|
||||
provider === 'hardcover'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
onClick={() => { setProvider('hardcover'); setValidationError(''); }}
|
||||
>
|
||||
Hardcover
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Visual Header */}
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
||||
{provider === 'goodreads' ? (
|
||||
<>
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
|
||||
<img src="/goodreads-icon.png" alt="Goodreads" className="w-5 h-5 object-contain" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Paste your Goodreads shelf RSS URL. Books will be automatically requested.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center ring-1 ring-indigo-200/50 dark:ring-indigo-500/10 flex-shrink-0">
|
||||
<img src="/hardcover-icon.svg" alt="Hardcover" className="w-6 h-6 object-contain" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Connect a Hardcover reading list and books will be automatically requested as you add them.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success Alert */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
{currentError && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">{currentError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{provider === 'goodreads' ? (
|
||||
<div>
|
||||
<Input
|
||||
type="url"
|
||||
label="Goodreads RSS URL"
|
||||
value={rssUrl}
|
||||
onChange={(e) => { setRssUrl(e.target.value); if (validationError) setValidationError(''); }}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
error={validationError}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
||||
Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<HardcoverForm
|
||||
apiToken={apiToken}
|
||||
setApiToken={setApiToken}
|
||||
listType={listType}
|
||||
setListType={setListType}
|
||||
statusId={statusId}
|
||||
setStatusId={setStatusId}
|
||||
customListId={customListId}
|
||||
setCustomListId={setCustomListId}
|
||||
validationError={validationError}
|
||||
setValidationError={setValidationError}
|
||||
isLoading={isLoading}
|
||||
success={success}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleClose} disabled={isLoading || success}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" loading={isLoading} disabled={isLoading || success}>
|
||||
Add Shelf
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Component: Hardcover Shelf Form
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from './Input';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status option definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{
|
||||
id: '1',
|
||||
label: 'Want to Read',
|
||||
description: 'Books saved to read later',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: 'Currently Reading',
|
||||
description: 'Books actively being read',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: 'Read',
|
||||
description: 'Books already finished',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
label: 'Did Not Finish',
|
||||
description: 'Books started but set aside',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HardcoverFormProps {
|
||||
apiToken: string;
|
||||
setApiToken: (v: string) => void;
|
||||
listType: 'status' | 'custom';
|
||||
setListType: (v: 'status' | 'custom') => void;
|
||||
statusId: string;
|
||||
setStatusId: (v: string) => void;
|
||||
customListId: string;
|
||||
setCustomListId: (v: string) => void;
|
||||
validationError: string;
|
||||
setValidationError: (v: string) => void;
|
||||
isLoading: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function HardcoverForm({
|
||||
apiToken, setApiToken,
|
||||
listType, setListType,
|
||||
statusId, setStatusId,
|
||||
customListId, setCustomListId,
|
||||
validationError, setValidationError,
|
||||
isLoading, success,
|
||||
}: HardcoverFormProps) {
|
||||
const disabled = isLoading || success;
|
||||
const isTokenError = validationError === 'Hardcover API Token is required';
|
||||
const isListError = !isTokenError && !!validationError;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* API Token */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
API Token
|
||||
</label>
|
||||
<a
|
||||
href="https://hardcover.app/account/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition-colors flex items-center gap-1 group"
|
||||
>
|
||||
Get your token
|
||||
<svg className="w-3 h-3 opacity-60 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => {
|
||||
setApiToken(e.target.value);
|
||||
if (isTokenError) setValidationError('');
|
||||
}}
|
||||
placeholder="Paste your Hardcover API token"
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'block w-full rounded-lg border px-4 py-2 text-sm transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'bg-white dark:bg-gray-800/60 text-gray-900 dark:text-white',
|
||||
'placeholder-gray-400 dark:placeholder-gray-500',
|
||||
isTokenError
|
||||
? 'border-red-400 dark:border-red-500'
|
||||
: 'border-gray-200 dark:border-gray-700',
|
||||
].join(' ')}
|
||||
/>
|
||||
{isTokenError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{validationError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
|
||||
Found under{' '}
|
||||
<span className="font-medium text-gray-500 dark:text-gray-400">Settings → API</span>
|
||||
{' '}on hardcover.app. Stored securely and never shared.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700/60" />
|
||||
|
||||
{/* List Type Selection */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Which list should we watch?
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Choose a reading status or one of your custom lists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<ListTypeCard
|
||||
active={listType === 'status'}
|
||||
onClick={() => setListType('status')}
|
||||
disabled={disabled}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />
|
||||
</svg>
|
||||
}
|
||||
title="Reading Status"
|
||||
subtitle="Want to Read, Reading, Read, etc."
|
||||
/>
|
||||
<ListTypeCard
|
||||
active={listType === 'custom'}
|
||||
onClick={() => setListType('custom')}
|
||||
disabled={disabled}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
}
|
||||
title="Custom List"
|
||||
subtitle="A list you created on Hardcover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status picker or Custom list input */}
|
||||
{listType === 'status' ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Status to sync</p>
|
||||
<div className="space-y-1.5">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<StatusRow
|
||||
key={opt.id}
|
||||
opt={opt}
|
||||
selected={statusId === opt.id}
|
||||
onSelect={() => setStatusId(opt.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
label="List URL or Slug"
|
||||
value={customListId}
|
||||
onChange={(e) => {
|
||||
setCustomListId(e.target.value);
|
||||
if (isListError) setValidationError('');
|
||||
}}
|
||||
placeholder="https://hardcover.app/@username/lists/..."
|
||||
error={isListError ? validationError : ''}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
|
||||
Paste the list URL from Hardcover, or enter just the slug (e.g.{' '}
|
||||
<code className="font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700/60 px-1 py-0.5 rounded text-[11px]">my-audiobooks</code>
|
||||
) or a numeric ID.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListTypeCard({
|
||||
active, onClick, disabled, icon, title, subtitle,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'relative text-left p-3 rounded-xl border-2 transition-all duration-150',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
active
|
||||
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50/70 dark:bg-indigo-500/[0.08]'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800/60',
|
||||
].join(' ')}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute top-2.5 right-2.5 w-2 h-2 rounded-full bg-indigo-500 dark:bg-indigo-400" />
|
||||
)}
|
||||
<div className={[
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center mb-2',
|
||||
active
|
||||
? 'bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
|
||||
].join(' ')}>
|
||||
{icon}
|
||||
</div>
|
||||
<p className={`text-sm font-medium leading-tight ${active ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{title}
|
||||
</p>
|
||||
<p className={`text-xs mt-0.5 leading-snug ${active ? 'text-indigo-500/80 dark:text-indigo-400/70' : 'text-gray-400 dark:text-gray-500'}`}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
opt, selected, onSelect, disabled,
|
||||
}: {
|
||||
opt: typeof STATUS_OPTIONS[number];
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-150 text-left',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
selected
|
||||
? 'border-indigo-400/70 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-500/[0.08]'
|
||||
: 'border-gray-200 dark:border-gray-700/80 bg-white dark:bg-gray-800/30 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/80 dark:hover:bg-gray-800/50',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={`flex-shrink-0 ${selected ? 'text-indigo-500 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500'}`}>
|
||||
{opt.icon}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className={`block text-sm font-medium ${selected ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{opt.description}
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-indigo-500 dark:text-indigo-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Component: Manage Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { GenericShelf } from '@/lib/hooks/useShelves';
|
||||
import { useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ManageShelfModalProps {
|
||||
shelf: GenericShelf | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) {
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
const [listId, setListId] = useState('');
|
||||
const [apiToken, setApiToken] = useState('');
|
||||
|
||||
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf();
|
||||
const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf();
|
||||
|
||||
// Reset form when shelf changes (use shelf?.id for stable reference)
|
||||
React.useEffect(() => {
|
||||
if (shelf) {
|
||||
setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : '');
|
||||
setListId(shelf.type === 'hardcover' ? shelf.sourceId : '');
|
||||
setApiToken('');
|
||||
}
|
||||
}, [shelf?.id]);
|
||||
|
||||
if (!shelf) return null;
|
||||
|
||||
const isUpdating = isUpdatingGoodreads || isUpdatingHardcover;
|
||||
const currentError = shelf.type === 'goodreads' ? goodreadsError : hardcoverError;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (shelf.type === 'goodreads') {
|
||||
if (!rssUrl.trim()) return;
|
||||
await updateGoodreads(shelf.id, rssUrl.trim());
|
||||
} else {
|
||||
if (!listId.trim()) return;
|
||||
await updateHardcover(shelf.id, {
|
||||
listId: listId.trim(),
|
||||
apiToken: apiToken.trim() || undefined,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// Error is handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
const isGoodreads = shelf.type === 'goodreads';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Manage ${shelf.name}`}>
|
||||
<div className="space-y-6">
|
||||
{currentError && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{currentError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{isGoodreads ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Goodreads RSS URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={rssUrl}
|
||||
onChange={(e) => setRssUrl(e.target.value)}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 dark:focus:ring-emerald-400 dark:text-white transition-colors"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Hardcover List ID or Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={listId}
|
||||
onChange={(e) => setListId(e.target.value)}
|
||||
placeholder="e.g., 1234, want-to-read, status-1"
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New API Token <span className="text-gray-400 dark:text-gray-500 font-normal">(Leave blank to keep current)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
placeholder="Paste your Hardcover token here..."
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUpdating}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium text-white rounded-xl shadow-sm transition-colors',
|
||||
isGoodreads
|
||||
? 'bg-amber-600 hover:bg-amber-700'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700',
|
||||
isUpdating && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Update & Re-sync'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,9 @@
|
||||
* Component: Unified Pagination — context-aware floating paginator
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Replaces two overlapping StickyPagination instances with a single pill
|
||||
* that automatically tracks which section dominates the viewport and shows
|
||||
* controls for that section. Transitions smoothly when the dominant section
|
||||
* changes. Includes a two-dot section indicator for manual switching.
|
||||
* A single floating pill that automatically tracks which section dominates
|
||||
* the viewport and shows pagination controls for that section.
|
||||
* Supports 1-12 sections dynamically with dot indicators for manual switching.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -28,7 +27,7 @@ export interface PaginationSection {
|
||||
}
|
||||
|
||||
interface UnifiedPaginationProps {
|
||||
sections: [PaginationSection, PaginationSection];
|
||||
sections: PaginationSection[];
|
||||
footerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
@@ -91,34 +90,152 @@ function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section indicator dots — scales gracefully from 2-12 sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SectionDotsProps {
|
||||
sections: PaginationSection[];
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* For 2-4 sections: simple vertical dot column (original behavior, unchanged).
|
||||
* For 5+ sections: iOS-style compressed window of 5 visible dots.
|
||||
* - Center slot = active section (full height, accent color)
|
||||
* - ±1 slots = neighboring sections (medium)
|
||||
* - ±2 slots = far neighbors (tiny, fade indicator)
|
||||
* Dots beyond the window are hidden entirely. The window slides as activeIndex changes.
|
||||
*/
|
||||
function SectionDots({ sections, activeIndex }: SectionDotsProps) {
|
||||
const count = sections.length;
|
||||
|
||||
// ---- Few sections: simple column ----
|
||||
if (count <= 4) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pl-2 pr-3">
|
||||
{sections.map((section, idx) => {
|
||||
const isActive = idx === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={`${section.label}-${idx}`}
|
||||
onClick={() => { if (!isActive) section.onScrollToSection(); }}
|
||||
disabled={isActive}
|
||||
title={section.label}
|
||||
aria-label={`Switch to ${section.label}`}
|
||||
className={`
|
||||
w-1.5 rounded-full transition-all duration-300 ease-out
|
||||
${isActive
|
||||
? `${section.accentColor} h-4 opacity-100`
|
||||
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Many sections: windowed 5-slot strip ----
|
||||
// The window is always 5 slots wide; we clamp it so it doesn't fall off edges.
|
||||
const WINDOW = 5;
|
||||
const half = Math.floor(WINDOW / 2); // 2
|
||||
|
||||
// Ideal window start: center the active dot
|
||||
let windowStart = activeIndex - half;
|
||||
// Clamp so window stays within [0, count - WINDOW]
|
||||
windowStart = Math.max(0, Math.min(windowStart, count - WINDOW));
|
||||
const windowEnd = windowStart + WINDOW - 1; // inclusive
|
||||
|
||||
// Distance from active within the window (for size calculation)
|
||||
// slots: [windowStart, windowStart+1, ..., windowEnd]
|
||||
const slots = Array.from({ length: WINDOW }, (_, i) => windowStart + i);
|
||||
|
||||
// Sizes: index 0 (dist 2 from active) → 2.5px, dist 1 → 4px, dist 0 (active) → 6px
|
||||
const heightForDist = [16, 10, 7, 5, 3]; // px — dist 0..4 (we only use 0-2)
|
||||
|
||||
// Whether we need overflow arrows (dots hidden beyond window edges)
|
||||
const hasHiddenLeft = windowStart > 0;
|
||||
const hasHiddenRight = windowEnd < count - 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5 pl-2 pr-3">
|
||||
{/* Top fade indicator */}
|
||||
{hasHiddenLeft && (
|
||||
<div
|
||||
className="w-0.5 rounded-full bg-gray-300 dark:bg-gray-600 opacity-30 flex-shrink-0"
|
||||
style={{ height: '3px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{slots.map((sectionIdx) => {
|
||||
const section = sections[sectionIdx];
|
||||
const isActive = sectionIdx === activeIndex;
|
||||
const dist = Math.abs(sectionIdx - activeIndex);
|
||||
const h = heightForDist[Math.min(dist, heightForDist.length - 1)];
|
||||
|
||||
// Active dot gets the section's accent color.
|
||||
// Inactive dots: the farther they are, the more faded.
|
||||
const opacityMap = [1, 0.55, 0.3];
|
||||
const opacity = opacityMap[Math.min(dist, opacityMap.length - 1)];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${section.label}-${sectionIdx}`}
|
||||
onClick={() => { if (!isActive) section.onScrollToSection(); }}
|
||||
disabled={isActive}
|
||||
title={section.label}
|
||||
aria-label={`Switch to ${section.label}`}
|
||||
style={{ height: `${h}px`, opacity }}
|
||||
className={`
|
||||
w-1.5 rounded-full flex-shrink-0
|
||||
transition-all duration-300 ease-out
|
||||
${isActive
|
||||
? `${section.accentColor} cursor-default`
|
||||
: 'bg-gray-400 dark:bg-gray-500 hover:opacity-90 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bottom fade indicator */}
|
||||
{hasHiddenRight && (
|
||||
<div
|
||||
className="w-0.5 rounded-full bg-gray-300 dark:bg-gray-600 opacity-30 flex-shrink-0"
|
||||
style={{ height: '3px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
|
||||
// Index of the currently dominant section (0 or 1)
|
||||
const [activeIndex, setActiveIndex] = useState<0 | 1>(0);
|
||||
// Whether the label+controls area is mid-transition (drives opacity fade)
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
const [footerVisible, setFooterVisible] = useState(false);
|
||||
// Per-section raw intersection ratios [0,1]
|
||||
const ratiosRef = useRef<[number, number]>([0, 0]);
|
||||
// Whether each section has any meaningful intersection
|
||||
const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]);
|
||||
const ratiosRef = useRef<number[]>(sections.map(() => 0));
|
||||
const [anySectionVisible, setAnySectionVisible] = useState(false);
|
||||
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Determine if the pill should be shown at all:
|
||||
// - at least one section is meaningfully visible
|
||||
// - footer is not visible
|
||||
// - the active section has >1 page
|
||||
const activeSectionHasPages = sections[activeIndex].totalPages > 1;
|
||||
const eitherSectionVisible = sectionVisible[0] || sectionVisible[1];
|
||||
const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages;
|
||||
// Keep ratios array length in sync with sections
|
||||
useEffect(() => {
|
||||
ratiosRef.current = sections.map((_, i) => ratiosRef.current[i] || 0);
|
||||
}, [sections.length]);
|
||||
|
||||
const activeSectionHasPages = sections[activeIndex]?.totalPages > 1;
|
||||
const shouldShow = anySectionVisible && !footerVisible && activeSectionHasPages && sections.length > 0;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Track which section each instance belongs to via intersection ratio
|
||||
// Intersection observers for all sections
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = [];
|
||||
@@ -128,38 +245,31 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio;
|
||||
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05;
|
||||
ratiosRef.current[idx] = entry.intersectionRatio;
|
||||
const anyVisible = ratiosRef.current.some((r) => r > 0.05);
|
||||
setAnySectionVisible(anyVisible);
|
||||
|
||||
setSectionVisible((prev) => {
|
||||
const next: [boolean, boolean] = [...prev] as [boolean, boolean];
|
||||
next[idx as 0 | 1] = isVisible;
|
||||
return next;
|
||||
});
|
||||
|
||||
// Determine dominant section (whichever has more viewport coverage)
|
||||
const [r0, r1] = ratiosRef.current;
|
||||
const dominant: 0 | 1 = r0 >= r1 ? 0 : 1;
|
||||
// Find dominant section
|
||||
let maxRatio = -1;
|
||||
let dominant = 0;
|
||||
for (let i = 0; i < ratiosRef.current.length; i++) {
|
||||
if (ratiosRef.current[i] > maxRatio) {
|
||||
maxRatio = ratiosRef.current[i];
|
||||
dominant = i;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveIndex((current) => {
|
||||
if (current !== dominant) {
|
||||
// Trigger cross-fade transition
|
||||
setIsTransitioning(true);
|
||||
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current);
|
||||
}
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 320);
|
||||
|
||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||
transitionTimerRef.current = setTimeout(() => setIsTransitioning(false), 320);
|
||||
return dominant;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
},
|
||||
{
|
||||
// Dense threshold array gives us smooth ratio tracking
|
||||
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
|
||||
rootMargin: '-60px 0px -80px 0px',
|
||||
}
|
||||
@@ -173,8 +283,9 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
observers.forEach((o) => o.disconnect());
|
||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
// Re-run when section refs change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sections[0].sectionRef, sections[1].sectionRef]);
|
||||
}, [sections.map((s) => s.sectionRef.current).join(',')]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Footer observer
|
||||
@@ -190,9 +301,10 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
}, [footerRef]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derived values for the currently active section
|
||||
// Derived values
|
||||
// ------------------------------------------------------------------
|
||||
const active = sections[activeIndex];
|
||||
if (!active) return null;
|
||||
|
||||
const handlePrev = () => {
|
||||
if (active.currentPage > 1) active.onPageChange(active.currentPage - 1);
|
||||
@@ -231,32 +343,14 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
"
|
||||
>
|
||||
{/* Section selector dots — left side */}
|
||||
<div className="flex flex-col gap-1 pl-2 pr-3">
|
||||
{sections.map((section, idx) => {
|
||||
const isActive = idx === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={section.label}
|
||||
onClick={() => {
|
||||
if (!isActive) section.onScrollToSection();
|
||||
}}
|
||||
disabled={isActive}
|
||||
title={section.label}
|
||||
aria-label={`Switch to ${section.label}`}
|
||||
className={`
|
||||
w-1.5 rounded-full transition-all duration-300 ease-out
|
||||
${isActive
|
||||
? `${section.accentColor} h-4 opacity-100`
|
||||
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sections.length > 1 && (
|
||||
<>
|
||||
<SectionDots sections={sections} activeIndex={activeIndex} />
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Label + controls — cross-fades on section switch */}
|
||||
<div
|
||||
@@ -265,11 +359,10 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
transition-opacity duration-200 ease-in-out
|
||||
${isTransitioning ? 'opacity-0' : 'opacity-100'}
|
||||
`}
|
||||
// key forces full remount on switch so input state resets cleanly
|
||||
key={activeIndex}
|
||||
>
|
||||
{/* Section label — hidden on small screens */}
|
||||
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none">
|
||||
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none max-w-[120px] truncate">
|
||||
{active.label}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Component: Shelf Hook Factory
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Generic hook factory for shelf CRUD operations. Each provider (Goodreads,
|
||||
* Hardcover, etc.) calls this with its API endpoint to get fully typed hooks
|
||||
* without duplicating the SWR/fetch/mutate boilerplate.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
export interface ShelfBook {
|
||||
coverUrl: string;
|
||||
asin: string | null;
|
||||
title: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
/**
|
||||
* Invalidate both the provider-specific endpoint and the combined /api/user/shelves endpoint.
|
||||
*/
|
||||
function revalidate(endpoint: string) {
|
||||
mutate((key) => typeof key === 'string' && key.includes(endpoint));
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/shelves'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a set of hooks for a shelf provider endpoint.
|
||||
*
|
||||
* Returns:
|
||||
* - useList: SWR-based hook to list shelves
|
||||
* - useAdd: Hook returning { addShelf(body), isLoading, error }
|
||||
* - useDelete: Hook returning { deleteShelf(id), isLoading, error }
|
||||
* - useUpdate: Hook returning { updateShelf(id, body), isLoading, error }
|
||||
*/
|
||||
export function createShelfHooks<TShelf>(endpoint: string) {
|
||||
function useList() {
|
||||
const { accessToken } = useAuth();
|
||||
const key = accessToken ? endpoint : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(key, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as TShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function useAdd() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addShelf = async (body: Record<string, unknown>) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to add shelf');
|
||||
}
|
||||
|
||||
revalidate(endpoint);
|
||||
return data.shelf as TShelf;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
}
|
||||
|
||||
function useDelete() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deleteShelf = async (shelfId: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to remove shelf');
|
||||
}
|
||||
|
||||
revalidate(endpoint);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteShelf, isLoading, error };
|
||||
}
|
||||
|
||||
function useUpdate() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const updateShelf = async (shelfId: string, body: Record<string, unknown>) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to update shelf');
|
||||
}
|
||||
|
||||
revalidate(endpoint);
|
||||
return data.shelf as TShelf;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { updateShelf, isLoading, error };
|
||||
}
|
||||
|
||||
return { useList, useAdd, useDelete, useUpdate };
|
||||
}
|
||||
@@ -5,17 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { createShelfHooks, ShelfBook } from './createShelfHooks';
|
||||
|
||||
export interface ShelfBook {
|
||||
coverUrl: string;
|
||||
asin: string | null;
|
||||
title: string;
|
||||
author: string;
|
||||
}
|
||||
export type { ShelfBook };
|
||||
|
||||
export interface GoodreadsShelf {
|
||||
id: string;
|
||||
@@ -27,101 +19,29 @@ export interface GoodreadsShelf {
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetchWithAuth(url).then((res) => res.json());
|
||||
const { useList, useAdd, useDelete, useUpdate } =
|
||||
createShelfHooks<GoodreadsShelf>('/api/user/goodreads-shelves');
|
||||
|
||||
export function useGoodreadsShelves() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
endpoint,
|
||||
fetcher,
|
||||
{ refreshInterval: 30000 }
|
||||
);
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as GoodreadsShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
export const useGoodreadsShelves = useList;
|
||||
|
||||
export function useAddGoodreadsShelf() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||
|
||||
const addShelf = async (rssUrl: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rssUrl }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to add shelf');
|
||||
}
|
||||
|
||||
// Revalidate shelves list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
|
||||
|
||||
return data.shelf as GoodreadsShelf;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return addGeneric({ rssUrl });
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
}
|
||||
|
||||
export function useDeleteGoodreadsShelf() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export const useDeleteGoodreadsShelf = useDelete;
|
||||
|
||||
const deleteShelf = async (shelfId: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
export function useUpdateGoodreadsShelf() {
|
||||
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to remove shelf');
|
||||
}
|
||||
|
||||
// Revalidate shelves list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
const updateShelf = async (shelfId: string, rssUrl: string) => {
|
||||
return updateGeneric(shelfId, { rssUrl });
|
||||
};
|
||||
|
||||
return { deleteShelf, isLoading, error };
|
||||
return { updateShelf, isLoading, error };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Component: Hardcover Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { createShelfHooks, ShelfBook } from './createShelfHooks';
|
||||
|
||||
export type { ShelfBook };
|
||||
|
||||
export interface HardcoverShelf {
|
||||
id: string;
|
||||
name: string;
|
||||
listId: string;
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const { useList, useAdd, useDelete, useUpdate } =
|
||||
createShelfHooks<HardcoverShelf>('/api/user/hardcover-shelves');
|
||||
|
||||
export const useHardcoverShelves = useList;
|
||||
|
||||
export function useAddHardcoverShelf() {
|
||||
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||
|
||||
const addShelf = async (apiToken: string, listId: string) => {
|
||||
return addGeneric({ apiToken, listId });
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
}
|
||||
|
||||
export const useDeleteHardcoverShelf = useDelete;
|
||||
|
||||
export function useUpdateHardcoverShelf() {
|
||||
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
||||
|
||||
const updateShelf = async (
|
||||
shelfId: string,
|
||||
updates: { listId?: string; apiToken?: string },
|
||||
) => {
|
||||
return updateGeneric(shelfId, updates);
|
||||
};
|
||||
|
||||
return { updateShelf, isLoading, error };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Component: Home Sections Hook
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Manages user home section configuration (CRUD) and category fetching.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR, { mutate as globalMutate } from 'swr';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
export interface HomeSection {
|
||||
id: string;
|
||||
sectionType: 'popular' | 'new_releases' | 'category';
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface HomeSectionsResponse {
|
||||
success: boolean;
|
||||
sections: HomeSection[];
|
||||
nextRefresh: string | null;
|
||||
}
|
||||
|
||||
export interface AudibleCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const HOME_SECTIONS_KEY = '/api/user/home-sections';
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage user home sections.
|
||||
*/
|
||||
export function useHomeSections() {
|
||||
const { data, error, isLoading, mutate } = useSWR<HomeSectionsResponse>(
|
||||
HOME_SECTIONS_KEY,
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
const saveSections = useCallback(
|
||||
async (sections: Omit<HomeSection, 'id'>[]) => {
|
||||
const { fetchJSON } = await import('@/lib/utils/api');
|
||||
const result = await fetchJSON<HomeSectionsResponse>(HOME_SECTIONS_KEY, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ sections }),
|
||||
});
|
||||
// Update local cache
|
||||
mutate(result, false);
|
||||
return result;
|
||||
},
|
||||
[mutate]
|
||||
);
|
||||
|
||||
return {
|
||||
sections: data?.sections || [],
|
||||
nextRefresh: data?.nextRefresh || null,
|
||||
isLoading,
|
||||
error,
|
||||
saveSections,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch Audible categories (live scrape, for config modal).
|
||||
*/
|
||||
export function useAudibleCategories() {
|
||||
const { data, error, isLoading } = useSWR<{ success: boolean; categories: AudibleCategory[] }>(
|
||||
null, // Don't fetch automatically — use fetchCategories
|
||||
authenticatedFetcher,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
return {
|
||||
categories: data?.categories || [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch category audiobooks (same pattern as useAudiobooks).
|
||||
*/
|
||||
export function useCategoryAudiobooks(
|
||||
categoryId: string | null,
|
||||
limit: number = 20,
|
||||
page: number = 1,
|
||||
hideAvailable: boolean = false
|
||||
) {
|
||||
const hideParam = hideAvailable ? '&hideAvailable=true' : '';
|
||||
const endpoint = categoryId
|
||||
? `/api/audiobooks/category/${categoryId}?page=${page}&limit=${limit}${hideParam}`
|
||||
: null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 60000,
|
||||
});
|
||||
|
||||
return {
|
||||
audiobooks: data?.audiobooks || [],
|
||||
totalCount: data?.totalCount || 0,
|
||||
totalPages: data?.totalPages || 0,
|
||||
currentPage: data?.page || page,
|
||||
hasMore: data?.hasMore || false,
|
||||
message: data?.message || null,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Component: Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ShelfBook } from './useGoodreadsShelves';
|
||||
|
||||
export interface GenericShelf {
|
||||
id: string;
|
||||
type: 'goodreads' | 'hardcover';
|
||||
name: string;
|
||||
sourceId: string; // Either rssUrl or listId
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useShelves() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/shelves' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as GenericShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -256,6 +256,15 @@ export class AudibleService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Don't retry on deterministic 500 errors (e.g. "Release date is in the future")
|
||||
if (status === 500) {
|
||||
const message = error.response?.data?.message || '';
|
||||
if (message.includes('Release date is in the future')) {
|
||||
logger.info(` External API returned non-retryable error: ${message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't retry on last attempt
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
@@ -1172,6 +1181,155 @@ export class AudibleService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top-level categories from Audible's categories page.
|
||||
* Scrapes {baseUrl}/categories and returns {id, name}[] for top-level nodes.
|
||||
*/
|
||||
async getCategories(): Promise<{ id: string; name: string }[]> {
|
||||
await this.initialize();
|
||||
|
||||
logger.info('Fetching Audible categories...');
|
||||
|
||||
try {
|
||||
const { data: response } = await this.fetchWithRetry('/categories', {
|
||||
params: { ipRedirectOverride: 'true' },
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
const categories: { id: string; name: string }[] = [];
|
||||
|
||||
// Top-level category links are in the main categories grid
|
||||
// They follow the pattern /cat/{name}/{nodeId}
|
||||
$('a[href*="/cat/"]').each((_index, element) => {
|
||||
const $el = $(element);
|
||||
const href = $el.attr('href') || '';
|
||||
const match = href.match(/\/cat\/[^\/]+\/(\d+)/);
|
||||
if (!match) return;
|
||||
|
||||
const id = match[1];
|
||||
const name = $el.text().trim();
|
||||
|
||||
if (name && !categories.some((c) => c.id === id)) {
|
||||
categories.push({ id, name });
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Found ${categories.length} top-level categories`);
|
||||
return categories;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch categories', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audiobooks for a specific category using Audible search with node parameter.
|
||||
* Scrapes {baseUrl}/search?node={categoryId}&pageSize=50, up to `limit` results.
|
||||
*/
|
||||
async getCategoryBooks(categoryId: string, limit: number = 200): Promise<AudibleAudiobook[]> {
|
||||
await this.initialize();
|
||||
|
||||
logger.info(`Fetching category books for node ${categoryId} (limit: ${limit})...`);
|
||||
|
||||
const audiobooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE);
|
||||
|
||||
this.pacer.reset();
|
||||
|
||||
while (audiobooks.length < limit && page <= maxPages) {
|
||||
try {
|
||||
const { data: response, meta } = await this.fetchWithRetry('/search', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
node: categoryId,
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
sort: 'popularity-rank',
|
||||
...(page > 1 ? { page } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
let foundOnPage = 0;
|
||||
|
||||
// Parse search results — same selectors as search()
|
||||
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||
if (audiobooks.length >= limit) return false;
|
||||
const $el = $(element);
|
||||
|
||||
const asin =
|
||||
$el.find('li').attr('data-asin') ||
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
'';
|
||||
if (!asin || audiobooks.some((b) => b.asin === asin)) return;
|
||||
|
||||
const title =
|
||||
$el.find('h2').first().text().trim() ||
|
||||
$el.find('h3 a').text().trim() ||
|
||||
$el.find('.bc-heading a').text().trim();
|
||||
|
||||
const authorLink = $el.find('a[href*="/author/"]').first();
|
||||
const authorText =
|
||||
authorLink.text().trim() ||
|
||||
$el.find('.authorLabel').text().trim();
|
||||
const authorHref = authorLink.attr('href') || '';
|
||||
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
|
||||
const narratorText =
|
||||
$el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim();
|
||||
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
const langConfig = this.getLangConfig();
|
||||
const runtimeText =
|
||||
$el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
const ratingText =
|
||||
$el.find('.ratingsLabel').text().trim() ||
|
||||
$el.find('.a-icon-star span').first().text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
audiobooks.push({
|
||||
asin,
|
||||
title,
|
||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
rating,
|
||||
});
|
||||
|
||||
foundOnPage++;
|
||||
});
|
||||
|
||||
logger.info(`Category ${categoryId}: found ${foundOnPage} books on page ${page}`);
|
||||
|
||||
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) break;
|
||||
|
||||
page++;
|
||||
|
||||
if (page <= maxPages && audiobooks.length < limit) {
|
||||
await this.delay(this.pacer.reportPageResult(meta));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch category ${categoryId} page ${page}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
collectedSoFar: audiobooks.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Category ${categoryId}: collected ${audiobooks.length} books across ${page - 1} pages`);
|
||||
return audiobooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add delay between requests to respect rate limits
|
||||
*/
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
* Component: Audible Refresh Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Fetches popular and new release audiobooks from Audible and caches them
|
||||
* Fetches popular, new release, and category audiobooks from Audible and caches them.
|
||||
* All section data is stored uniformly in AudibleCacheCategory with reserved IDs
|
||||
* '__popular__' and '__new_releases__' for built-in sections.
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/** Reserved category IDs for built-in home sections */
|
||||
export const POPULAR_CATEGORY_ID = '__popular__';
|
||||
export const NEW_RELEASES_CATEGORY_ID = '__new_releases__';
|
||||
|
||||
export interface AudibleRefreshPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
@@ -25,22 +31,7 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
const thumbnailCache = getThumbnailCacheService();
|
||||
|
||||
try {
|
||||
// Clear previous popular/new-release flags for fresh data
|
||||
await prisma.audibleCache.updateMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ isPopular: true },
|
||||
{ isNewRelease: true },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
isPopular: false,
|
||||
isNewRelease: false,
|
||||
popularRank: null,
|
||||
newReleaseRank: null,
|
||||
},
|
||||
});
|
||||
logger.info('Cleared previous popular/new-release flags in audible_cache');
|
||||
const syncTime = new Date();
|
||||
|
||||
// Fetch popular and new releases - 200 items each
|
||||
const popular = await audibleService.getPopularAudiobooks(200);
|
||||
@@ -54,113 +45,63 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
|
||||
logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
|
||||
|
||||
// Persist to audible_cache
|
||||
let popularSaved = 0;
|
||||
let newReleasesSaved = 0;
|
||||
const syncTime = new Date();
|
||||
// Persist popular audiobooks via AudibleCacheCategory
|
||||
const popularSaved = await persistSectionBooks(
|
||||
popular, POPULAR_CATEGORY_ID, syncTime, thumbnailCache, logger, 'popular audiobook'
|
||||
);
|
||||
|
||||
for (let i = 0; i < popular.length; i++) {
|
||||
const audiobook = popular[i];
|
||||
try {
|
||||
// Cache thumbnail if coverArtUrl exists
|
||||
let cachedCoverPath: string | null = null;
|
||||
if (audiobook.coverArtUrl) {
|
||||
cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl);
|
||||
// Persist new releases via AudibleCacheCategory
|
||||
const newReleasesSaved = await persistSectionBooks(
|
||||
newReleases, NEW_RELEASES_CATEGORY_ID, syncTime, thumbnailCache, logger, 'new release'
|
||||
);
|
||||
|
||||
logger.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases`);
|
||||
|
||||
// --- Category scraping ---
|
||||
// Query distinct categoryIds from all users' home sections
|
||||
let categoriesSynced = 0;
|
||||
try {
|
||||
const categorySections = await prisma.userHomeSection.findMany({
|
||||
where: { sectionType: 'category', categoryId: { not: null } },
|
||||
select: { categoryId: true },
|
||||
distinct: ['categoryId'],
|
||||
});
|
||||
|
||||
const categoryIds = categorySections
|
||||
.map((s) => s.categoryId)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
logger.info(`Refreshing ${categoryIds.length} user-configured categories...`);
|
||||
|
||||
for (const catId of categoryIds) {
|
||||
try {
|
||||
// Batch cooldown between categories
|
||||
const catCooldownMs = 10000 + Math.floor(Math.random() * 10000);
|
||||
logger.info(`Category cooldown: waiting ${Math.round(catCooldownMs / 1000)}s before category ${catId}...`);
|
||||
await new Promise(resolve => setTimeout(resolve, catCooldownMs));
|
||||
|
||||
// Scrape category books
|
||||
const books = await audibleService.getCategoryBooks(catId, 200);
|
||||
logger.info(`Category ${catId}: fetched ${books.length} books`);
|
||||
|
||||
const saved = await persistSectionBooks(
|
||||
books, catId, syncTime, thumbnailCache, logger, 'category book'
|
||||
);
|
||||
|
||||
categoriesSynced++;
|
||||
logger.info(`Category ${catId}: saved ${saved} entries`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to refresh category ${catId}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.audibleCache.upsert({
|
||||
where: { asin: audiobook.asin },
|
||||
create: {
|
||||
asin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
cachedCoverPath: cachedCoverPath,
|
||||
durationMinutes: audiobook.durationMinutes,
|
||||
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
|
||||
rating: audiobook.rating ? audiobook.rating : null,
|
||||
genres: audiobook.genres || [],
|
||||
isPopular: true,
|
||||
popularRank: i + 1,
|
||||
lastSyncedAt: syncTime,
|
||||
},
|
||||
update: {
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
cachedCoverPath: cachedCoverPath,
|
||||
durationMinutes: audiobook.durationMinutes,
|
||||
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
|
||||
rating: audiobook.rating ? audiobook.rating : null,
|
||||
genres: audiobook.genres || [],
|
||||
isPopular: true,
|
||||
popularRank: i + 1,
|
||||
lastSyncedAt: syncTime,
|
||||
},
|
||||
});
|
||||
|
||||
popularSaved++;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save popular audiobook ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.info(`Category refresh complete: ${categoriesSynced}/${categoryIds.length} categories synced`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Category refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < newReleases.length; i++) {
|
||||
const audiobook = newReleases[i];
|
||||
try {
|
||||
// Cache thumbnail if coverArtUrl exists
|
||||
let cachedCoverPath: string | null = null;
|
||||
if (audiobook.coverArtUrl) {
|
||||
cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl);
|
||||
}
|
||||
|
||||
await prisma.audibleCache.upsert({
|
||||
where: { asin: audiobook.asin },
|
||||
create: {
|
||||
asin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
cachedCoverPath: cachedCoverPath,
|
||||
durationMinutes: audiobook.durationMinutes,
|
||||
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
|
||||
rating: audiobook.rating ? audiobook.rating : null,
|
||||
genres: audiobook.genres || [],
|
||||
isNewRelease: true,
|
||||
newReleaseRank: i + 1,
|
||||
lastSyncedAt: syncTime,
|
||||
},
|
||||
update: {
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
cachedCoverPath: cachedCoverPath,
|
||||
durationMinutes: audiobook.durationMinutes,
|
||||
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
|
||||
rating: audiobook.rating ? audiobook.rating : null,
|
||||
genres: audiobook.genres || [],
|
||||
isNewRelease: true,
|
||||
newReleaseRank: i + 1,
|
||||
lastSyncedAt: syncTime,
|
||||
},
|
||||
});
|
||||
|
||||
newReleasesSaved++;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save new release ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases to audible_cache`);
|
||||
|
||||
// Cleanup unused thumbnails
|
||||
logger.info('Cleaning up unused thumbnails...');
|
||||
const allActiveAsins = await prisma.audibleCache.findMany({
|
||||
@@ -175,6 +116,7 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
message: 'Audible refresh completed',
|
||||
popularSaved,
|
||||
newReleasesSaved,
|
||||
categoriesSynced,
|
||||
thumbnailsDeleted: deletedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -182,3 +124,87 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe previous entries for a category, upsert book metadata into AudibleCache,
|
||||
* and insert ranked entries into AudibleCacheCategory.
|
||||
* Returns the number of books successfully saved.
|
||||
*/
|
||||
async function persistSectionBooks(
|
||||
books: any[],
|
||||
categoryId: string,
|
||||
syncTime: Date,
|
||||
thumbnailCache: { cacheThumbnail: (asin: string, url: string) => Promise<string | null> },
|
||||
logger: ReturnType<typeof RMABLogger.forJob>,
|
||||
labelForErrors: string,
|
||||
): Promise<number> {
|
||||
// Wipe previous entries for this section
|
||||
logger.info(`Clearing previous data for ${categoryId}...`);
|
||||
await prisma.audibleCacheCategory.deleteMany({
|
||||
where: { categoryId },
|
||||
});
|
||||
logger.info(`Cleared previous entries for ${categoryId}, saving ${books.length} books...`);
|
||||
|
||||
let saved = 0;
|
||||
for (let i = 0; i < books.length; i++) {
|
||||
const book = books[i];
|
||||
try {
|
||||
// Cache thumbnail if coverArtUrl exists
|
||||
let cachedCoverPath: string | null = null;
|
||||
if (book.coverArtUrl) {
|
||||
cachedCoverPath = await thumbnailCache.cacheThumbnail(book.asin, book.coverArtUrl);
|
||||
if (!cachedCoverPath) {
|
||||
logger.warn(`Cover cache failed for "${book.title}" (${book.asin}) - falling back to remote URL`);
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert book metadata into AudibleCache
|
||||
await prisma.audibleCache.upsert({
|
||||
where: { asin: book.asin },
|
||||
create: {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator,
|
||||
description: book.description,
|
||||
coverArtUrl: book.coverArtUrl,
|
||||
cachedCoverPath,
|
||||
durationMinutes: book.durationMinutes,
|
||||
releaseDate: book.releaseDate ? new Date(book.releaseDate) : null,
|
||||
rating: book.rating ? book.rating : null,
|
||||
genres: book.genres || [],
|
||||
lastSyncedAt: syncTime,
|
||||
},
|
||||
update: {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator,
|
||||
description: book.description,
|
||||
coverArtUrl: book.coverArtUrl,
|
||||
cachedCoverPath,
|
||||
durationMinutes: book.durationMinutes,
|
||||
releaseDate: book.releaseDate ? new Date(book.releaseDate) : null,
|
||||
rating: book.rating ? book.rating : null,
|
||||
genres: book.genres || [],
|
||||
lastSyncedAt: syncTime,
|
||||
},
|
||||
});
|
||||
|
||||
// Insert ranked entry into AudibleCacheCategory
|
||||
await prisma.audibleCacheCategory.create({
|
||||
data: {
|
||||
asin: book.asin,
|
||||
categoryId,
|
||||
rank: i + 1,
|
||||
lastSyncedAt: syncTime,
|
||||
},
|
||||
});
|
||||
|
||||
saved++;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save ${labelForErrors} ${book.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function processStartDirectDownload(payload: StartDirectDownloadPay
|
||||
// Get download configuration
|
||||
const configService = getConfigService();
|
||||
const downloadsDir = await configService.get('download_dir') || '/downloads';
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.gl';
|
||||
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
|
||||
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ async function searchAnnasArchive(
|
||||
logger: RMABLogger
|
||||
): Promise<EbookSearchResult | null> {
|
||||
const configService = getConfigService();
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.gl';
|
||||
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
|
||||
|
||||
// Get language code from Audible region config
|
||||
|
||||
@@ -166,9 +166,10 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
|
||||
// Rank results with indexer priorities and flag configs
|
||||
// Note: rankTorrents now filters out results < 20 MB internally
|
||||
// Use effectiveSearchTitle so custom search terms are respected for ranking
|
||||
// requireAuthor: true (default) - strict filtering for automatic selection
|
||||
const rankedResults = ranker.rankTorrents(searchResults, {
|
||||
title: audiobook.title,
|
||||
title: effectiveSearchTitle,
|
||||
author: audiobook.author,
|
||||
durationMinutes,
|
||||
}, {
|
||||
@@ -228,7 +229,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
// Log top 3 results with detailed breakdown
|
||||
const top3 = filteredResults.slice(0, 3);
|
||||
logger.info(`==================== RANKING DEBUG ====================`);
|
||||
logger.info(`Requested Title: "${audiobook.title}"`);
|
||||
logger.info(`Ranking Title: "${effectiveSearchTitle}"${effectiveSearchTitle !== audiobook.title ? ` (audiobook: "${audiobook.title}")` : ''}`);
|
||||
logger.info(`Requested Author: "${audiobook.author}"`);
|
||||
logger.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
|
||||
logger.info(`--------------------------------------------------------`);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Component: Sync Goodreads Shelves Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Dedicated processor for syncing Goodreads shelf RSS feeds.
|
||||
* Resolves books to Audible ASINs and creates requests.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SyncGoodreadsShelvesPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process this specific shelf (used for immediate sync on add) */
|
||||
shelfId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise<any> {
|
||||
const { jobId, shelfId, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves');
|
||||
|
||||
logger.info(shelfId
|
||||
? `Starting immediate Goodreads sync for shelf ${shelfId}...`
|
||||
: 'Starting scheduled Goodreads shelves sync...'
|
||||
);
|
||||
|
||||
const { processGoodreadsShelves } = await import('../services/goodreads-sync.service');
|
||||
const stats = await processGoodreadsShelves(logger, {
|
||||
shelfId,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
logger.info('Goodreads sync complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Component: Sync Shelves Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Dedicated processor for syncing all reading shelves (Goodreads, Hardcover).
|
||||
* Resolves books to Audible ASINs and creates requests.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SyncShelvesPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process this specific shelf (used for immediate sync on add) */
|
||||
shelfId?: string;
|
||||
/** The type of shelf, if shelfId is specified */
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export async function processSyncShelves(
|
||||
payload: SyncShelvesPayload,
|
||||
): Promise<any> {
|
||||
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
|
||||
|
||||
const stats = {
|
||||
shelvesProcessed: 0,
|
||||
booksFound: 0,
|
||||
lookupsPerformed: 0,
|
||||
requestsCreated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
logger.info(
|
||||
shelfId
|
||||
? `Starting immediate ${shelfType} sync for list ${shelfId}...`
|
||||
: 'Starting scheduled shelves sync...',
|
||||
);
|
||||
|
||||
const shouldSyncGoodreads = !shelfType || shelfType === 'goodreads';
|
||||
const shouldSyncHardcover = !shelfType || shelfType === 'hardcover';
|
||||
|
||||
if (shouldSyncGoodreads) {
|
||||
try {
|
||||
const { processGoodreadsShelves } =
|
||||
await import('../services/goodreads-sync.service');
|
||||
const grStats = await processGoodreadsShelves(logger, {
|
||||
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
stats.shelvesProcessed += grStats.shelvesProcessed;
|
||||
stats.booksFound += grStats.booksFound;
|
||||
stats.lookupsPerformed += grStats.lookupsPerformed;
|
||||
stats.requestsCreated += grStats.requestsCreated;
|
||||
stats.errors += grStats.errors;
|
||||
} catch (error) {
|
||||
logger.error('Goodreads sync failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSyncHardcover) {
|
||||
try {
|
||||
const { processHardcoverShelves } =
|
||||
await import('../services/hardcover-sync.service');
|
||||
const hcStats = await processHardcoverShelves(logger, {
|
||||
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
stats.shelvesProcessed += hcStats.shelvesProcessed;
|
||||
stats.booksFound += hcStats.booksFound;
|
||||
stats.lookupsPerformed += hcStats.lookupsPerformed;
|
||||
stats.requestsCreated += hcStats.requestsCreated;
|
||||
stats.errors += hcStats.errors;
|
||||
} catch (error) {
|
||||
logger.error('Hardcover sync failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Shelves sync complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: shelfId ? `${shelfType} list synced` : 'Reading shelves synced',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -128,7 +128,7 @@ async function fetchHtml(
|
||||
*/
|
||||
export async function testFlareSolverrConnection(
|
||||
flaresolverrUrl: string,
|
||||
baseUrl: string = 'https://annas-archive.li'
|
||||
baseUrl: string = 'https://annas-archive.gl'
|
||||
): Promise<{ success: boolean; message: string; responseTime?: number }> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -168,7 +168,7 @@ export async function downloadEbook(
|
||||
author: string,
|
||||
targetDir: string,
|
||||
preferredFormat: string = 'epub',
|
||||
baseUrl: string = 'https://annas-archive.li',
|
||||
baseUrl: string = 'https://annas-archive.gl',
|
||||
logger?: RMABLogger,
|
||||
flaresolverrUrl?: string,
|
||||
languageCode: string = 'en'
|
||||
|
||||
@@ -2,36 +2,29 @@
|
||||
* Component: Goodreads Shelf Sync Service
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*
|
||||
* Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs,
|
||||
* and creates requests via the shared request-creator service.
|
||||
* Fetches Goodreads shelf RSS feeds and delegates book processing
|
||||
* to the shared shelf-sync-core service.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import {
|
||||
ShelfBook,
|
||||
ShelfSyncStats,
|
||||
ShelfSyncOptions,
|
||||
createEmptyStats,
|
||||
resolveMaxLookups,
|
||||
processShelfBooks,
|
||||
} from '@/lib/services/shelf-sync-core.service';
|
||||
|
||||
const logger = RMABLogger.create('GoodreadsSync');
|
||||
|
||||
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
||||
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
||||
|
||||
/** Days before retrying a noMatch book */
|
||||
const NO_MATCH_RETRY_DAYS = 7;
|
||||
|
||||
interface GoodreadsRssBook {
|
||||
bookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Goodreads RSS feed XML into structured book data.
|
||||
*/
|
||||
function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } {
|
||||
function parseGoodreadsRss(xml: string): { shelfName: string; books: ShelfBook[] } {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
@@ -46,65 +39,84 @@ function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRs
|
||||
|
||||
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
|
||||
|
||||
// Normalize items to array
|
||||
let items = channel.item;
|
||||
if (!items) return { shelfName, books: [] };
|
||||
if (!Array.isArray(items)) items = [items];
|
||||
|
||||
const books: GoodreadsRssBook[] = [];
|
||||
const books: ShelfBook[] = [];
|
||||
for (const item of items) {
|
||||
const bookId = item.book_id?.toString();
|
||||
if (!bookId) continue;
|
||||
|
||||
const title = (item.title || '').toString().trim();
|
||||
const authorName = (item.author_name || '').toString().trim();
|
||||
// Goodreads RSS has book_image_url or book_medium_image_url
|
||||
const author = (item.author_name || '').toString().trim();
|
||||
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
|
||||
|
||||
if (title && authorName) {
|
||||
books.push({ bookId, title, author: authorName, coverUrl });
|
||||
if (title && author) {
|
||||
books.push({ bookId, title, author, coverUrl });
|
||||
}
|
||||
}
|
||||
|
||||
return { shelfName, books };
|
||||
}
|
||||
|
||||
/** Max items Goodreads returns per RSS page */
|
||||
const GOODREADS_PAGE_SIZE = 100;
|
||||
|
||||
/** Safety cap to avoid infinite loops */
|
||||
const MAX_PAGES = 50;
|
||||
|
||||
/**
|
||||
* Fetch and validate a Goodreads RSS URL.
|
||||
* Returns the parsed shelf name and books if valid.
|
||||
* Automatically paginates (sort=title, page=1,2,...) when a page returns 100 items.
|
||||
* Deduplicates by bookId across pages.
|
||||
*/
|
||||
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> {
|
||||
const response = await axios.get(rssUrl, { timeout: 15000 });
|
||||
return parseGoodreadsRss(response.data);
|
||||
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: ShelfBook[] }> {
|
||||
const url = new URL(rssUrl);
|
||||
url.searchParams.set('sort', 'title');
|
||||
|
||||
let shelfName = 'Goodreads Shelf';
|
||||
const seenIds = new Set<string>();
|
||||
const allBooks: ShelfBook[] = [];
|
||||
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
url.searchParams.set('page', page.toString());
|
||||
|
||||
const response = await axios.get(url.toString(), { timeout: 15000 });
|
||||
const parsed = parseGoodreadsRss(response.data);
|
||||
|
||||
if (page === 1) {
|
||||
shelfName = parsed.shelfName;
|
||||
}
|
||||
|
||||
for (const book of parsed.books) {
|
||||
if (!seenIds.has(book.bookId)) {
|
||||
seenIds.add(book.bookId);
|
||||
allBooks.push(book);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.books.length < GOODREADS_PAGE_SIZE) break;
|
||||
}
|
||||
|
||||
return { shelfName, books: allBooks };
|
||||
}
|
||||
|
||||
export interface GoodreadsSyncStats {
|
||||
shelvesProcessed: number;
|
||||
booksFound: number;
|
||||
lookupsPerformed: number;
|
||||
requestsCreated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface GoodreadsSyncOptions {
|
||||
/** Process only this shelf ID (for immediate single-shelf sync) */
|
||||
shelfId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
// Re-export types that downstream consumers expect
|
||||
export type { ShelfSyncStats as GoodreadsSyncStats };
|
||||
export type { ShelfSyncOptions as GoodreadsSyncOptions };
|
||||
|
||||
/**
|
||||
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
|
||||
* Called from the dedicated sync_goodreads_shelves processor.
|
||||
* Called from the unified sync_reading_shelves processor.
|
||||
*/
|
||||
export async function processGoodreadsShelves(
|
||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||
options: GoodreadsSyncOptions = {}
|
||||
): Promise<GoodreadsSyncStats> {
|
||||
options: ShelfSyncOptions = {}
|
||||
): Promise<ShelfSyncStats> {
|
||||
const log = jobLogger || logger;
|
||||
const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
|
||||
|
||||
const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
||||
const stats = createEmptyStats();
|
||||
const maxLookups = resolveMaxLookups(options);
|
||||
|
||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
||||
const shelves = await prisma.goodreadsShelf.findMany({
|
||||
@@ -121,7 +133,32 @@ export async function processGoodreadsShelves(
|
||||
|
||||
for (const shelf of shelves) {
|
||||
try {
|
||||
await processShelf(shelf, stats, log, maxLookups);
|
||||
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||
|
||||
let rssData: { shelfName: string; books: ShelfBook[] };
|
||||
try {
|
||||
rssData = await fetchAndValidateRss(shelf.rssUrl);
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
stats.errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"`);
|
||||
|
||||
const bookData = await processShelfBooks(
|
||||
'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
|
||||
);
|
||||
|
||||
await prisma.goodreadsShelf.update({
|
||||
where: { id: shelf.id },
|
||||
data: {
|
||||
lastSyncAt: new Date(),
|
||||
bookCount: rssData.books.length,
|
||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||
},
|
||||
});
|
||||
|
||||
stats.shelvesProcessed++;
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
@@ -132,238 +169,3 @@ export async function processGoodreadsShelves(
|
||||
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
async function processShelf(
|
||||
shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } },
|
||||
stats: GoodreadsSyncStats,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
maxLookups: number
|
||||
) {
|
||||
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||
|
||||
let rssData: { shelfName: string; books: GoodreadsRssBook[] };
|
||||
try {
|
||||
rssData = await fetchAndValidateRss(shelf.rssUrl);
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const books = rssData.books;
|
||||
stats.booksFound += books.length;
|
||||
log.info(`Found ${books.length} books in shelf "${shelf.name}"`);
|
||||
|
||||
let lookupsThisCycle = 0;
|
||||
const unlimitedLookups = maxLookups === 0;
|
||||
|
||||
for (const book of books) {
|
||||
// Look up existing mapping
|
||||
let mapping = await prisma.goodreadsBookMapping.findUnique({
|
||||
where: { goodreadsBookId: book.bookId },
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
// No mapping exists — perform Audible lookup if under cap
|
||||
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) {
|
||||
continue; // Will be resolved in a future cycle
|
||||
}
|
||||
|
||||
mapping = await performAudibleLookup(book, log);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
// If lookup found an ASIN, fall through to create request immediately
|
||||
if (!mapping?.audibleAsin) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping exists with noMatch — check if we should retry
|
||||
if (mapping.noMatch) {
|
||||
if (mapping.lastSearchAt) {
|
||||
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
|
||||
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
|
||||
mapping = await performAudibleLookup(book, log, mapping.id);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
// If retry found an ASIN, fall through to create request
|
||||
if (!mapping?.audibleAsin) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue; // Still no match, skip
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping has ASIN — try to create request
|
||||
if (mapping.audibleAsin) {
|
||||
try {
|
||||
const result = await createRequestForUser(shelf.user.id, {
|
||||
asin: mapping.audibleAsin,
|
||||
title: mapping.title,
|
||||
author: mapping.author,
|
||||
coverArtUrl: mapping.coverUrl || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
stats.requestsCreated++;
|
||||
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
|
||||
}
|
||||
// If not success, it's already available/requested/duplicate — silently skip
|
||||
} catch (error) {
|
||||
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect enriched book data (coverUrl + ASIN) for display
|
||||
const bookIds = books.map(b => b.bookId);
|
||||
const mappings = bookIds.length > 0
|
||||
? await prisma.goodreadsBookMapping.findMany({
|
||||
where: { goodreadsBookId: { in: bookIds } },
|
||||
select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
|
||||
})
|
||||
: [];
|
||||
const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m]));
|
||||
|
||||
// Look up AudibleCache records for high-quality cached cover URLs
|
||||
const matchedAsins = mappings
|
||||
.map(m => m.audibleAsin)
|
||||
.filter((asin): asin is string => !!asin);
|
||||
const cachedCovers = matchedAsins.length > 0
|
||||
? await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: matchedAsins } },
|
||||
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
|
||||
})
|
||||
: [];
|
||||
const coverByAsin = new Map(
|
||||
cachedCovers
|
||||
.filter(c => c.cachedCoverPath || c.coverArtUrl)
|
||||
.map(c => {
|
||||
let coverUrl = c.coverArtUrl || '';
|
||||
if (c.cachedCoverPath) {
|
||||
const filename = c.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
return [c.asin, coverUrl] as const;
|
||||
})
|
||||
);
|
||||
|
||||
const bookData = books
|
||||
.map(b => {
|
||||
const mapping = mappingsByBookId.get(b.bookId);
|
||||
// Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover
|
||||
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
|
||||
if (!coverUrl) return null;
|
||||
return {
|
||||
coverUrl,
|
||||
asin: mapping?.audibleAsin || null,
|
||||
title: mapping?.title || b.title,
|
||||
author: mapping?.author || b.author,
|
||||
};
|
||||
})
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.slice(0, 8);
|
||||
|
||||
// Update shelf metadata
|
||||
await prisma.goodreadsShelf.update({
|
||||
where: { id: shelf.id },
|
||||
data: {
|
||||
lastSyncAt: new Date(),
|
||||
bookCount: books.length,
|
||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function performAudibleLookup(
|
||||
book: GoodreadsRssBook,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
existingMappingId?: string
|
||||
): Promise<any> {
|
||||
const audibleService = getAudibleService();
|
||||
|
||||
try {
|
||||
// Try full Goodreads title first, then fall back to stripped title
|
||||
// (Goodreads titles often include series info like "(Demonica, #2)" that return 0 Audible results)
|
||||
const fullQuery = `${book.title} ${book.author}`;
|
||||
log.info(`Searching Audible for: "${fullQuery}"`);
|
||||
|
||||
let searchResult = await audibleService.search(fullQuery);
|
||||
let firstResult = searchResult.results[0];
|
||||
|
||||
if (!firstResult?.asin) {
|
||||
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
|
||||
if (cleanTitle !== book.title) {
|
||||
const cleanQuery = `${cleanTitle} ${book.author}`;
|
||||
log.info(`No results with full title, retrying without series info: "${cleanQuery}"`);
|
||||
searchResult = await audibleService.search(cleanQuery);
|
||||
firstResult = searchResult.results[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (firstResult?.asin) {
|
||||
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
|
||||
|
||||
// Use clean Audible/Audnexus metadata instead of Goodreads data
|
||||
// (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches)
|
||||
const data = {
|
||||
title: firstResult.title,
|
||||
author: firstResult.author,
|
||||
audibleAsin: firstResult.asin,
|
||||
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
|
||||
noMatch: false,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
// No match found
|
||||
log.info(`No Audible match for "${book.title}" by ${book.author}`);
|
||||
|
||||
const noMatchData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
audibleAsin: null,
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...noMatchData },
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Still create/update mapping so we don't retry every cycle
|
||||
const errorData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...errorData },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Component: Hardcover API Service
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*
|
||||
* GraphQL queries and API communication with the Hardcover platform.
|
||||
* Exports fetchHardcoverList for use by the sync orchestration layer.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('HardcoverAPI');
|
||||
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
|
||||
|
||||
export interface HardcoverApiBook {
|
||||
bookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
/** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */
|
||||
interface HardcoverBookNode {
|
||||
id?: number;
|
||||
title?: string;
|
||||
cached_image?: string | { url?: string };
|
||||
image?: { url?: string };
|
||||
contributions?: Array<{ author?: { name?: string } }>;
|
||||
}
|
||||
|
||||
/** Shape of a list object returned from the Hardcover GraphQL API */
|
||||
interface HardcoverListData {
|
||||
name?: string;
|
||||
list_books?: Array<{ book?: HardcoverBookNode }>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 50;
|
||||
|
||||
/** Extract HardcoverApiBook[] from an array of book-containing items */
|
||||
function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] {
|
||||
const books: HardcoverApiBook[] = [];
|
||||
for (const item of items) {
|
||||
const book = item.book;
|
||||
if (!book || !book.id) continue;
|
||||
|
||||
const authorName =
|
||||
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
||||
const cachedImg = book.cached_image;
|
||||
const coverUrl =
|
||||
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) ||
|
||||
book.image?.url ||
|
||||
undefined;
|
||||
|
||||
books.push({
|
||||
bookId: book.id.toString(),
|
||||
title: book.title || 'Unknown Title',
|
||||
author: authorName,
|
||||
coverUrl,
|
||||
});
|
||||
}
|
||||
return books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Hardcover List using their GraphQL API.
|
||||
* This handles both 'status_id' user_books or 'list_id' list_books queries.
|
||||
* For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id.
|
||||
*/
|
||||
export async function fetchHardcoverList(
|
||||
apiToken: string,
|
||||
listIdStr: string,
|
||||
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
|
||||
// Check if it's a status list
|
||||
const isStatus = listIdStr.startsWith('status-');
|
||||
|
||||
if (isStatus) {
|
||||
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
||||
const query = `
|
||||
query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) {
|
||||
me {
|
||||
user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) {
|
||||
book {
|
||||
id
|
||||
title
|
||||
contributions {
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
cached_image
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Map status numbers to names
|
||||
const statusNames: Record<number, string> = {
|
||||
1: 'Want to Read',
|
||||
2: 'Currently Reading',
|
||||
3: 'Read',
|
||||
4: 'Did Not Finish',
|
||||
};
|
||||
const listName = statusNames[statusId] || `Status ${statusId}`;
|
||||
|
||||
const allBooks: HardcoverApiBook[] = [];
|
||||
let offset = 0;
|
||||
let page = 0;
|
||||
|
||||
// Paginate until fewer results than PAGE_SIZE are returned
|
||||
while (++page <= MAX_PAGES) {
|
||||
const response = await axios.post(
|
||||
HARDCOVER_API_URL,
|
||||
{ query, variables: { statusId, limit: PAGE_SIZE, offset } },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data?.errors) {
|
||||
throw new Error(
|
||||
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const userBooks: Array<{ book?: HardcoverBookNode }> =
|
||||
response.data?.data?.me?.[0]?.user_books || [];
|
||||
const pageBooks = extractBooks(userBooks);
|
||||
allBooks.push(...pageBooks);
|
||||
|
||||
if (userBooks.length < PAGE_SIZE) break;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
|
||||
return { listName, books: allBooks };
|
||||
} else {
|
||||
// Custom list query
|
||||
// - URL with @username → query that user's lists by slug
|
||||
// - Bare slug (no username) → query authenticated user's lists via `me`
|
||||
// - Numeric ID → query globally (IDs are unique)
|
||||
const isIntId = /^\d+$/.test(listIdStr);
|
||||
let extractedSlug = listIdStr;
|
||||
let extractedUsername: string | null = null;
|
||||
|
||||
if (!isIntId) {
|
||||
try {
|
||||
if (listIdStr.includes('hardcover.app')) {
|
||||
const url = new URL(
|
||||
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
|
||||
);
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
// URL format: /@username/lists/slug
|
||||
if (parts.length > 0) {
|
||||
extractedSlug = parts[parts.length - 1];
|
||||
}
|
||||
const userPart = parts.find((p) => p.startsWith('@'));
|
||||
if (userPart) {
|
||||
extractedUsername = userPart.slice(1);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// use extractedSlug as-is
|
||||
}
|
||||
}
|
||||
|
||||
const listBookFields = `
|
||||
name
|
||||
list_books(limit: $limit, offset: $offset, order_by: {id: desc}) {
|
||||
book {
|
||||
id title cached_image image { url }
|
||||
contributions { author { name } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Numeric ID: globally unique, query the lists table directly
|
||||
const queryById = `
|
||||
query GetListBooks($listId: Int!, $limit: Int!, $offset: Int!) {
|
||||
lists(where: {id: {_eq: $listId}}, limit: 1) {
|
||||
${listBookFields}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Slug with username: query through the users table to scope to that user
|
||||
const queryByUserSlug = `
|
||||
query GetUserListBySlug($username: citext!, $slug: String!, $limit: Int!, $offset: Int!) {
|
||||
users(where: {username: {_eq: $username}}, limit: 1) {
|
||||
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
||||
${listBookFields}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Bare slug (no username): scope to the authenticated user via `me`
|
||||
const queryByMySlug = `
|
||||
query GetMyListBySlug($slug: String!, $limit: Int!, $offset: Int!) {
|
||||
me {
|
||||
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
||||
${listBookFields}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let activeQuery: string;
|
||||
let baseVariables: Record<string, unknown>;
|
||||
|
||||
if (isIntId) {
|
||||
activeQuery = queryById;
|
||||
baseVariables = { listId: parseInt(listIdStr, 10) };
|
||||
} else if (extractedUsername) {
|
||||
activeQuery = queryByUserSlug;
|
||||
baseVariables = { username: extractedUsername, slug: extractedSlug };
|
||||
} else {
|
||||
activeQuery = queryByMySlug;
|
||||
baseVariables = { slug: extractedSlug };
|
||||
}
|
||||
|
||||
// First request to discover list metadata and first page of books
|
||||
const firstResponse = await axios.post(
|
||||
HARDCOVER_API_URL,
|
||||
{
|
||||
query: activeQuery,
|
||||
variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (firstResponse.data?.errors) {
|
||||
throw new Error(
|
||||
`Hardcover API Error: ${firstResponse.data.errors[0]?.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract lists array from the response based on which query was used
|
||||
let listsData: HardcoverListData[];
|
||||
if (isIntId) {
|
||||
listsData = firstResponse.data?.data?.lists || [];
|
||||
} else if (extractedUsername) {
|
||||
const users = firstResponse.data?.data?.users || [];
|
||||
listsData = users[0]?.lists || [];
|
||||
} else {
|
||||
listsData = firstResponse.data?.data?.me?.[0]?.lists || [];
|
||||
}
|
||||
|
||||
if (listsData.length === 0) {
|
||||
let identifier: string;
|
||||
if (isIntId) {
|
||||
identifier = `ID "${listIdStr}"`;
|
||||
} else if (extractedUsername) {
|
||||
identifier = `slug "${extractedSlug}" for user @${extractedUsername}`;
|
||||
} else {
|
||||
identifier = `slug "${extractedSlug}" in your Hardcover account`;
|
||||
}
|
||||
throw new Error(`Could not find a list with ${identifier}`);
|
||||
}
|
||||
|
||||
const listName = listsData[0].name || 'Hardcover List';
|
||||
const firstPageItems = listsData[0].list_books || [];
|
||||
const allBooks = extractBooks(firstPageItems);
|
||||
|
||||
// Paginate if first page was full
|
||||
if (firstPageItems.length >= PAGE_SIZE) {
|
||||
let offset = PAGE_SIZE;
|
||||
let page = 1; // first page already fetched
|
||||
|
||||
while (++page <= MAX_PAGES) {
|
||||
const pageResponse = await axios.post(
|
||||
HARDCOVER_API_URL,
|
||||
{
|
||||
query: activeQuery,
|
||||
variables: { ...baseVariables, limit: PAGE_SIZE, offset },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (pageResponse.data?.errors) {
|
||||
logger.warn('Hardcover pagination interrupted by API error', {
|
||||
errors: pageResponse.data.errors,
|
||||
offset,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
let pageListsData: HardcoverListData[];
|
||||
if (isIntId) {
|
||||
pageListsData = pageResponse.data?.data?.lists || [];
|
||||
} else if (extractedUsername) {
|
||||
const users = pageResponse.data?.data?.users || [];
|
||||
pageListsData = users[0]?.lists || [];
|
||||
} else {
|
||||
pageListsData = pageResponse.data?.data?.me?.[0]?.lists || [];
|
||||
}
|
||||
|
||||
const pageItems = pageListsData[0]?.list_books || [];
|
||||
allBooks.push(...extractBooks(pageItems));
|
||||
|
||||
if (pageItems.length < PAGE_SIZE) break;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
return { listName, books: allBooks };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Component: Hardcover Shelf Sync Service
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*
|
||||
* Fetches Hardcover lists via GraphQL API and delegates book processing
|
||||
* to the shared shelf-sync-core service.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||
import {
|
||||
ShelfSyncStats,
|
||||
ShelfSyncOptions,
|
||||
createEmptyStats,
|
||||
resolveMaxLookups,
|
||||
processShelfBooks,
|
||||
} from '@/lib/services/shelf-sync-core.service';
|
||||
|
||||
export { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
|
||||
export type { HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||
|
||||
const logger = RMABLogger.create('HardcoverSync');
|
||||
|
||||
// Re-export types that downstream consumers expect
|
||||
export type { ShelfSyncStats as HardcoverSyncStats };
|
||||
export type { ShelfSyncOptions as HardcoverSyncOptions };
|
||||
|
||||
/**
|
||||
* Process Hardcover shelves: fetch lists via GraphQL, resolve ASINs, create requests.
|
||||
* Called from the unified sync_reading_shelves processor.
|
||||
*/
|
||||
export async function processHardcoverShelves(
|
||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||
options: ShelfSyncOptions = {},
|
||||
): Promise<ShelfSyncStats> {
|
||||
const log = jobLogger || logger;
|
||||
const stats = createEmptyStats();
|
||||
const maxLookups = resolveMaxLookups(options);
|
||||
|
||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
||||
const shelves = await prisma.hardcoverShelf.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
if (shelves.length === 0) {
|
||||
log.info(
|
||||
options.shelfId
|
||||
? 'Hardcover list not found'
|
||||
: 'No Hardcover lists configured, skipping',
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Processing ${shelves.length} Hardcover list(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/list)` : ' (unlimited lookups)'}`,
|
||||
);
|
||||
|
||||
for (const shelf of shelves) {
|
||||
try {
|
||||
log.info(`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
let decryptedToken = shelf.apiToken;
|
||||
try {
|
||||
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
|
||||
decryptedToken = encryptionService.decrypt(shelf.apiToken);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(`Failed to decrypt API token for user ${shelf.user.plexUsername}`);
|
||||
stats.errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let fetchedData: { listName: string; books: HardcoverApiBook[] };
|
||||
try {
|
||||
fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
stats.errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)`);
|
||||
|
||||
const bookData = await processShelfBooks(
|
||||
'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
|
||||
);
|
||||
|
||||
const finalListName =
|
||||
fetchedData.listName !== 'Hardcover List'
|
||||
? fetchedData.listName
|
||||
: shelf.name;
|
||||
|
||||
await prisma.hardcoverShelf.update({
|
||||
where: { id: shelf.id },
|
||||
data: {
|
||||
name: finalListName,
|
||||
lastSyncAt: new Date(),
|
||||
bookCount: fetchedData.books.length,
|
||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||
},
|
||||
});
|
||||
|
||||
stats.shelvesProcessed++;
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
log.error(
|
||||
`Failed to process list "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Hardcover sync complete: ${stats.shelvesProcessed} lists, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`,
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export type JobType =
|
||||
| 'retry_failed_imports'
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds'
|
||||
| 'sync_goodreads_shelves'
|
||||
| 'sync_reading_shelves'
|
||||
| 'check_watched_lists'
|
||||
| 'send_notification'
|
||||
// Ebook-specific job types
|
||||
@@ -108,9 +108,10 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
|
||||
export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
||||
export interface SyncShelvesPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
shelfId?: string;
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
@@ -389,10 +390,10 @@ export class JobQueueService {
|
||||
return await processCleanupSeededTorrents(payloadWithJobId);
|
||||
});
|
||||
|
||||
this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
|
||||
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves');
|
||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
||||
this.queue.process('sync_reading_shelves', 1, async (job: BullJob<SyncShelvesPayload>) => {
|
||||
const { processSyncShelves } = await import('../processors/sync-shelves.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_reading_shelves');
|
||||
return await processSyncShelves(payloadWithJobId);
|
||||
});
|
||||
|
||||
this.queue.process('check_watched_lists', 1, async (job: BullJob<CheckWatchedListsPayload>) => {
|
||||
@@ -767,16 +768,17 @@ export class JobQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sync Goodreads shelves job
|
||||
* Add sync reading shelves job
|
||||
*/
|
||||
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> {
|
||||
async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise<string> {
|
||||
return await this.addJob(
|
||||
'sync_goodreads_shelves',
|
||||
'sync_reading_shelves',
|
||||
{
|
||||
scheduledJobId,
|
||||
shelfId,
|
||||
shelfType,
|
||||
maxLookupsPerShelf,
|
||||
} as SyncGoodreadsShelvesPayload,
|
||||
} as SyncShelvesPayload,
|
||||
{
|
||||
priority: 7,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Scheduler');
|
||||
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' | 'check_watched_lists';
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves' | 'check_watched_lists';
|
||||
|
||||
export interface ScheduledJob {
|
||||
id: string;
|
||||
@@ -59,6 +59,9 @@ export class SchedulerService {
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up deprecated scheduled jobs
|
||||
await this.cleanupDeprecatedJobs();
|
||||
|
||||
// Create default jobs if they don't exist
|
||||
await this.ensureDefaultJobs();
|
||||
|
||||
@@ -127,8 +130,8 @@ export class SchedulerService {
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
name: 'Sync Goodreads Shelves',
|
||||
type: 'sync_goodreads_shelves' as ScheduledJobType,
|
||||
name: 'Sync Reading Shelves',
|
||||
type: 'sync_reading_shelves' as ScheduledJobType,
|
||||
schedule: '0 */6 * * *', // Every 6 hours
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
@@ -174,6 +177,31 @@ export class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any old jobs that are no longer supported
|
||||
*/
|
||||
private async cleanupDeprecatedJobs(): Promise<void> {
|
||||
try {
|
||||
const deprecatedTypes = ['sync_goodreads_shelves'];
|
||||
|
||||
const obsoleteJobs = await prisma.scheduledJob.findMany({
|
||||
where: { type: { in: deprecatedTypes } },
|
||||
});
|
||||
|
||||
for (const job of obsoleteJobs) {
|
||||
if (job.enabled) {
|
||||
await this.unscheduleJob(job);
|
||||
}
|
||||
await prisma.scheduledJob.delete({ where: { id: job.id } });
|
||||
logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup deprecated scheduled jobs', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule all enabled jobs
|
||||
*/
|
||||
@@ -357,8 +385,8 @@ export class SchedulerService {
|
||||
case 'monitor_rss_feeds':
|
||||
bullJobId = await this.triggerMonitorRssFeeds(job);
|
||||
break;
|
||||
case 'sync_goodreads_shelves':
|
||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
||||
case 'sync_reading_shelves':
|
||||
bullJobId = await this.triggerSyncShelves(job);
|
||||
break;
|
||||
case 'check_watched_lists':
|
||||
bullJobId = await this.triggerCheckWatchedLists(job);
|
||||
@@ -632,10 +660,10 @@ export class SchedulerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Goodreads shelves sync
|
||||
* Trigger Reading shelves sync
|
||||
*/
|
||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
||||
private async triggerSyncShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncShelvesJob(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Component: Shelf Sync Core Service
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*
|
||||
* Shared logic for all shelf providers: Audible lookup, noMatch retry,
|
||||
* request creation, cover enrichment, and shelf metadata updates.
|
||||
* Provider-specific services (Goodreads, Hardcover) call into this core.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { BookMapping } from '@/generated/prisma';
|
||||
|
||||
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
||||
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
||||
|
||||
/** Days before retrying a noMatch book */
|
||||
const NO_MATCH_RETRY_DAYS = 7;
|
||||
|
||||
/** Provider-agnostic book from any shelf source */
|
||||
export interface ShelfBook {
|
||||
bookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
/** Sync stats shared across all providers */
|
||||
export interface ShelfSyncStats {
|
||||
shelvesProcessed: number;
|
||||
booksFound: number;
|
||||
lookupsPerformed: number;
|
||||
requestsCreated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** Common sync options */
|
||||
export interface ShelfSyncOptions {
|
||||
shelfId?: string;
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
type LoggerType = ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>;
|
||||
|
||||
export function createEmptyStats(): ShelfSyncStats {
|
||||
return { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
|
||||
}
|
||||
|
||||
export function mergeStats(target: ShelfSyncStats, source: ShelfSyncStats): void {
|
||||
target.shelvesProcessed += source.shelvesProcessed;
|
||||
target.booksFound += source.booksFound;
|
||||
target.lookupsPerformed += source.lookupsPerformed;
|
||||
target.requestsCreated += source.requestsCreated;
|
||||
target.errors += source.errors;
|
||||
}
|
||||
|
||||
export function resolveMaxLookups(options: ShelfSyncOptions): number {
|
||||
return options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a list of books from any provider: resolve to ASINs, create requests,
|
||||
* enrich covers, and return book data for shelf metadata.
|
||||
*/
|
||||
export async function processShelfBooks(
|
||||
provider: string,
|
||||
books: ShelfBook[],
|
||||
userId: string,
|
||||
shelfId: string,
|
||||
stats: ShelfSyncStats,
|
||||
log: LoggerType,
|
||||
maxLookups: number,
|
||||
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
||||
stats.booksFound += books.length;
|
||||
|
||||
let lookupsThisCycle = 0;
|
||||
const unlimitedLookups = maxLookups === 0;
|
||||
|
||||
for (const book of books) {
|
||||
let mapping = await prisma.bookMapping.findUnique({
|
||||
where: { provider_externalBookId: { provider, externalBookId: book.bookId } },
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue;
|
||||
|
||||
mapping = await performAudibleLookup(provider, book, log);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
if (!mapping?.audibleAsin) continue;
|
||||
}
|
||||
|
||||
if (mapping.noMatch) {
|
||||
if (mapping.lastSearchAt) {
|
||||
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
|
||||
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
|
||||
mapping = await performAudibleLookup(provider, book, log, mapping.id);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
if (!mapping?.audibleAsin) continue;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.audibleAsin) {
|
||||
try {
|
||||
const result = await createRequestForUser(userId, {
|
||||
asin: mapping.audibleAsin,
|
||||
title: mapping.title,
|
||||
author: mapping.author,
|
||||
coverArtUrl: mapping.coverUrl || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
stats.requestsCreated++;
|
||||
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enrichBookCovers(provider, books);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich book list with cached cover URLs from AudibleCache.
|
||||
* Returns up to 8 books with the best available cover URL.
|
||||
*/
|
||||
async function enrichBookCovers(
|
||||
provider: string,
|
||||
books: ShelfBook[],
|
||||
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
||||
const bookIds = books.map(b => b.bookId);
|
||||
const mappings = bookIds.length > 0
|
||||
? await prisma.bookMapping.findMany({
|
||||
where: { provider, externalBookId: { in: bookIds } },
|
||||
select: { externalBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
|
||||
})
|
||||
: [];
|
||||
const mappingsByBookId = new Map(mappings.map(m => [m.externalBookId, m]));
|
||||
|
||||
const matchedAsins = mappings
|
||||
.map(m => m.audibleAsin)
|
||||
.filter((asin): asin is string => !!asin);
|
||||
const cachedCovers = matchedAsins.length > 0
|
||||
? await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: matchedAsins } },
|
||||
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
|
||||
})
|
||||
: [];
|
||||
const coverByAsin = new Map(
|
||||
cachedCovers
|
||||
.filter(c => c.cachedCoverPath || c.coverArtUrl)
|
||||
.map(c => {
|
||||
let coverUrl = c.coverArtUrl || '';
|
||||
if (c.cachedCoverPath) {
|
||||
const filename = c.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
return [c.asin, coverUrl] as const;
|
||||
})
|
||||
);
|
||||
|
||||
return books
|
||||
.map(b => {
|
||||
const mapping = mappingsByBookId.get(b.bookId);
|
||||
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
|
||||
if (!coverUrl) return null;
|
||||
return {
|
||||
coverUrl,
|
||||
asin: mapping?.audibleAsin || null,
|
||||
title: mapping?.title || b.title,
|
||||
author: mapping?.author || b.author,
|
||||
};
|
||||
})
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Audible for a book, persist the result to the unified BookMapping table.
|
||||
*/
|
||||
async function performAudibleLookup(
|
||||
provider: string,
|
||||
book: ShelfBook,
|
||||
log: LoggerType,
|
||||
existingMappingId?: string,
|
||||
): Promise<BookMapping | null> {
|
||||
const audibleService = getAudibleService();
|
||||
|
||||
try {
|
||||
const fullQuery = `${book.title} ${book.author}`;
|
||||
log.info(`Searching Audible for: "${fullQuery}"`);
|
||||
|
||||
let searchResult = await audibleService.search(fullQuery);
|
||||
let firstResult = searchResult.results[0];
|
||||
|
||||
if (!firstResult?.asin) {
|
||||
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
|
||||
if (cleanTitle !== book.title) {
|
||||
const cleanQuery = `${cleanTitle} ${book.author}`;
|
||||
log.info(`No results with full title, retrying without series info: "${cleanQuery}"`);
|
||||
searchResult = await audibleService.search(cleanQuery);
|
||||
firstResult = searchResult.results[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (firstResult?.asin) {
|
||||
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
|
||||
|
||||
const data = {
|
||||
title: firstResult.title,
|
||||
author: firstResult.author,
|
||||
audibleAsin: firstResult.asin,
|
||||
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
|
||||
noMatch: false,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.bookMapping.update({ where: { id: existingMappingId }, data });
|
||||
}
|
||||
return prisma.bookMapping.create({
|
||||
data: { provider, externalBookId: book.bookId, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`No Audible match for "${book.title}" by ${book.author}`);
|
||||
|
||||
const noMatchData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
audibleAsin: null,
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.bookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
|
||||
}
|
||||
return prisma.bookMapping.create({
|
||||
data: { provider, externalBookId: book.bookId, ...noMatchData },
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
const errorData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.bookMapping.update({ where: { id: existingMappingId }, data: errorData });
|
||||
}
|
||||
return prisma.bookMapping.create({
|
||||
data: { provider, externalBookId: book.bookId, ...errorData },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class ThumbnailCacheService {
|
||||
try {
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create cache directory', { error: error instanceof Error ? error.message : String(error) });
|
||||
logger.error(`Failed to create cache directory: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class ThumbnailCacheService {
|
||||
try {
|
||||
await fs.mkdir(LIBRARY_CACHE_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create library cache directory', { error: error instanceof Error ? error.message : String(error) });
|
||||
logger.error(`Failed to create library cache directory: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -127,8 +127,8 @@ export class ThumbnailCacheService {
|
||||
logger.info(`Cached thumbnail for ${asin}: ${filePath}`);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
// Log error but don't throw - we'll fall back to the original URL
|
||||
logger.error(`Failed to cache thumbnail for ${asin}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
// Log warning but don't throw - we'll fall back to the original URL
|
||||
logger.warn(`Failed to cache thumbnail for ${asin}: ${error instanceof Error ? error.message : String(error)} - will use remote URL`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -203,10 +203,8 @@ export class ThumbnailCacheService {
|
||||
logger.info(`Cached library thumbnail for ${plexGuid}: ${filePath}`);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
// Log error but don't throw - graceful degradation
|
||||
logger.warn(`Failed to cache library thumbnail for ${plexGuid}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Log warning but don't throw - graceful degradation
|
||||
logger.warn(`Failed to cache library thumbnail for ${plexGuid}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -227,7 +225,7 @@ export class ThumbnailCacheService {
|
||||
logger.info(`Deleted thumbnail: ${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete thumbnail for ${asin}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
logger.error(`Failed to delete thumbnail for ${asin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +256,7 @@ export class ThumbnailCacheService {
|
||||
logger.info(`Cleanup complete: ${deletedCount} thumbnails deleted`);
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup thumbnails', { error: error instanceof Error ? error.message : String(error) });
|
||||
logger.error(`Failed to cleanup thumbnails: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -299,9 +297,7 @@ export class ThumbnailCacheService {
|
||||
logger.info(`Library cleanup complete: ${deletedCount} thumbnails deleted`);
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup library thumbnails', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
logger.error(`Failed to cleanup library thumbnails: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { AudibleAudiobook } from '../integrations/audible.service';
|
||||
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */
|
||||
const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi;
|
||||
|
||||
/** Trailing subtitle after colon or long dash */
|
||||
/** Trailing subtitle after colon or long dash (used for extraction, not blind stripping) */
|
||||
const SUBTITLE_RE = /\s*[:]\s+.+$/;
|
||||
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
|
||||
|
||||
@@ -44,6 +44,44 @@ export function normalizeTitle(title: string): string {
|
||||
return t.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the subtitle portion from a title (part after colon or long dash).
|
||||
* Returns empty string if no subtitle found.
|
||||
* Used to prevent false dedup of series books like "Series: Book A" vs "Series: Book B".
|
||||
*/
|
||||
export function extractSubtitle(title: string): string {
|
||||
let t = title.toLowerCase();
|
||||
// Remove parenthesized/bracketed edition markers first (same as normalizeTitle)
|
||||
t = t.replace(EDITION_PAREN_RE, '');
|
||||
// Remove trailing descriptors
|
||||
t = t.replace(TRAILING_DESCRIPTOR_RE, '');
|
||||
t = t.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Try colon subtitle
|
||||
const colonMatch = t.match(/\s*[:]\s+(.+)$/);
|
||||
if (colonMatch) return colonMatch[1].trim();
|
||||
|
||||
// Try long dash subtitle
|
||||
const dashMatch = t.match(/\s+[-\u2013\u2014]\s+(.+)$/);
|
||||
if (dashMatch) return dashMatch[1].trim();
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two titles' subtitles are compatible for dedup purposes.
|
||||
* - Both have no subtitle → compatible
|
||||
* - One has a subtitle, other doesn't → compatible (re-listing with/without subtitle)
|
||||
* - Both have the SAME subtitle → compatible
|
||||
* - Both have DIFFERENT subtitles → NOT compatible (different books, e.g. series entries)
|
||||
*/
|
||||
function areSubtitlesCompatible(titleA: string, titleB: string): boolean {
|
||||
const subA = extractSubtitle(titleA);
|
||||
const subB = extractSubtitle(titleB);
|
||||
if (!subA || !subB) return true; // one or both missing → compatible
|
||||
return subA === subB;
|
||||
}
|
||||
|
||||
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
|
||||
function normalizeNarrator(narrator?: string): string {
|
||||
const raw = (narrator || '').toLowerCase().trim();
|
||||
@@ -152,16 +190,20 @@ export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): Deduplic
|
||||
continue;
|
||||
}
|
||||
|
||||
// Within a title+narrator group, further split by duration compatibility.
|
||||
// Build sub-groups where all members are duration-compatible with the
|
||||
// representative (first member). A book joins the first compatible sub-group.
|
||||
// Within a title+narrator group, further split by duration AND subtitle
|
||||
// compatibility. Build sub-groups where all members are compatible with
|
||||
// the representative (first member). A book joins the first compatible sub-group.
|
||||
// This prevents false dedup of series entries like "Series: Book A" vs "Series: Book B".
|
||||
const subGroups: AudibleAudiobook[][] = [];
|
||||
|
||||
for (const book of group) {
|
||||
let placed = false;
|
||||
for (const sg of subGroups) {
|
||||
// Check compatibility against the representative (first member)
|
||||
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) {
|
||||
// Check both duration and subtitle compatibility against the representative
|
||||
if (
|
||||
areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes) &&
|
||||
areSubtitlesCompatible(sg[0].title, book.title)
|
||||
) {
|
||||
sg.push(book);
|
||||
placed = true;
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Component: Shelf Helpers
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a JSON string of cover/book data into a typed array.
|
||||
* Returns an empty array on parse failure (graceful degradation).
|
||||
*/
|
||||
export function processBooks(
|
||||
coverUrls: string | null,
|
||||
): { coverUrl: string; asin: string | null; title: string; author: string }[] {
|
||||
if (!coverUrls) return [];
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(coverUrls);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.map((item: unknown) => {
|
||||
if (typeof item === 'string') {
|
||||
return { coverUrl: item, asin: null, title: '', author: '' };
|
||||
}
|
||||
const obj = item as Record<string, unknown>;
|
||||
return {
|
||||
coverUrl: (obj.coverUrl as string) || '',
|
||||
asin: (obj.asin as string) || null,
|
||||
title: (obj.title as string) || '',
|
||||
author: (obj.author as string) || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Component: Admin Manual Import API Route Tests
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
let requestBody: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addOrganizeJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
getAudiobookDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
// fs mock
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
AuthenticatedRequest: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => fsMock);
|
||||
|
||||
vi.mock('path', async () => {
|
||||
const actual = await vi.importActual<typeof import('path')>('path');
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
resolve: (...args: string[]) => actual.posix.resolve(...args),
|
||||
extname: actual.posix.extname,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Admin manual-import route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requestBody = { asin: 'B00TEST0001', folderPath: '/bookdrop/author/title' };
|
||||
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
|
||||
// Default: download_dir and media_dir not configured, bookdrop exists
|
||||
prismaMock.configuration.findUnique.mockResolvedValue(null);
|
||||
fsMock.stat.mockImplementation(async (p: string) => {
|
||||
if (p === '/bookdrop') return { isDirectory: () => true };
|
||||
if (p === '/bookdrop/author/title') return { isDirectory: () => true };
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
fsMock.readdir.mockResolvedValue([
|
||||
{ name: 'chapter1.m4b', isFile: () => true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates audiobook from Audnexus when ASIN is not in DB or cache', async () => {
|
||||
// Neither audiobook nor audibleCache has this ASIN
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
// Audnexus returns live data
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
|
||||
asin: 'B00TEST0001',
|
||||
title: 'Live Title',
|
||||
author: 'Live Author',
|
||||
coverArtUrl: 'https://example.com/cover.jpg',
|
||||
narrator: 'Live Narrator',
|
||||
series: 'Test Series',
|
||||
seriesPart: '1',
|
||||
seriesAsin: 'SERIES0001',
|
||||
releaseDate: '2024-01-15',
|
||||
});
|
||||
|
||||
// audiobook.create returns the new record
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||
id: 'ab-new',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Live Title',
|
||||
author: 'Live Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// audiobook.findUnique for the verification step
|
||||
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
|
||||
id: 'ab-new',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Live Title',
|
||||
author: 'Live Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// No existing request
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-new' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(audibleServiceMock.getAudiobookDetails).toHaveBeenCalledWith('B00TEST0001');
|
||||
expect(prismaMock.audiobook.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Live Title',
|
||||
author: 'Live Author',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 when ASIN is not in DB, cache, or Audnexus', async () => {
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Audiobook not found for the given ASIN');
|
||||
});
|
||||
|
||||
it('returns 404 when Audnexus lookup throws an error', async () => {
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
|
||||
audibleServiceMock.getAudiobookDetails.mockRejectedValueOnce(new Error('Network timeout'));
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Audiobook not found for the given ASIN');
|
||||
});
|
||||
|
||||
it('uses existing audiobook record when ASIN is in DB', async () => {
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce({
|
||||
id: 'ab-existing',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
});
|
||||
|
||||
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
|
||||
id: 'ab-existing',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Existing Title',
|
||||
author: 'Existing Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
// Should NOT have queried audibleCache for ASIN resolution
|
||||
expect(prismaMock.audibleCache.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses audibleCache when ASIN is not in audiobook table but is cached', async () => {
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
||||
asin: 'B00TEST0001',
|
||||
title: 'Cached Title',
|
||||
author: 'Cached Author',
|
||||
coverArtUrl: 'https://example.com/cached.jpg',
|
||||
narrator: 'Cached Narrator',
|
||||
});
|
||||
|
||||
// audiobook.create from cache
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||
id: 'ab-from-cache',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Cached Title',
|
||||
author: 'Cached Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
|
||||
id: 'ab-from-cache',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Cached Title',
|
||||
author: 'Cached Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-2' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
// audiobook.create should have used cache data, not Audnexus
|
||||
expect(prismaMock.audiobook.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
title: 'Cached Title',
|
||||
author: 'Cached Author',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -355,7 +355,7 @@ describe('Admin settings core routes', () => {
|
||||
|
||||
it('updates ebook settings', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.li' }),
|
||||
json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.gl' }),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/ebook/route');
|
||||
|
||||
@@ -348,14 +348,14 @@ describe('Admin settings test routes', () => {
|
||||
|
||||
it('tests FlareSolverr connection', async () => {
|
||||
testFlareSolverrMock.mockResolvedValueOnce({ success: true });
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.gl' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.li');
|
||||
expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.gl');
|
||||
});
|
||||
|
||||
it('rejects FlareSolverr test when URL is missing', async () => {
|
||||
@@ -382,7 +382,7 @@ describe('Admin settings test routes', () => {
|
||||
|
||||
it('returns error when FlareSolverr test throws', async () => {
|
||||
testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down'));
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.gl' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
||||
const response = await POST(request as any);
|
||||
|
||||
@@ -68,6 +68,12 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('returns popular audiobooks with cached cover URLs', async () => {
|
||||
// Mock AudibleCacheCategory query (popular route now queries category table)
|
||||
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', rank: 1 },
|
||||
]);
|
||||
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(1);
|
||||
// Mock AudibleCache metadata fetch
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
asin: 'ASIN',
|
||||
@@ -84,7 +90,6 @@ describe('Audiobooks browse routes', () => {
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
|
||||
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', coverArtUrl: '/api/cache/thumbnails/asin.jpg' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/popular/route');
|
||||
@@ -106,8 +111,9 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('returns new release audiobooks', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(0);
|
||||
// Mock AudibleCacheCategory query (new-releases route now queries category table)
|
||||
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(0);
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/new-releases/route');
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any);
|
||||
@@ -118,6 +124,12 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('enriches new releases and uses cached cover URLs', async () => {
|
||||
// Mock AudibleCacheCategory query
|
||||
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', rank: 1 },
|
||||
]);
|
||||
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(1);
|
||||
// Mock AudibleCache metadata fetch
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
asin: 'ASIN',
|
||||
@@ -134,7 +146,6 @@ describe('Audiobooks browse routes', () => {
|
||||
lastSyncedAt: new Date('2024-01-02'),
|
||||
},
|
||||
]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
|
||||
currentUserMock.mockReturnValue({ sub: 'user-1' });
|
||||
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', available: true }]);
|
||||
|
||||
@@ -155,7 +166,7 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('returns 500 when new releases query fails', async () => {
|
||||
prismaMock.audibleCache.findMany.mockRejectedValueOnce(new Error('db down'));
|
||||
prismaMock.audibleCacheCategory.findMany.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/new-releases/route');
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any);
|
||||
@@ -209,6 +220,11 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('returns cached covers for login', async () => {
|
||||
// Mock AudibleCacheCategory query (covers route now queries category table)
|
||||
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN' },
|
||||
]);
|
||||
// Mock AudibleCache metadata fetch
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null },
|
||||
]);
|
||||
@@ -221,5 +237,3 @@ describe('Audiobooks browse routes', () => {
|
||||
expect(payload.covers[0].coverUrl).toBe('/api/cache/thumbnails/asin.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Component: Goodreads Shelves [id] API Route Tests
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
const SHELF = {
|
||||
id: 'shelf-1',
|
||||
userId: 'user-1',
|
||||
name: 'Want to Read',
|
||||
rssUrl: 'https://www.goodreads.com/review/list_rss/12345',
|
||||
lastSyncAt: null,
|
||||
bookCount: 5,
|
||||
coverUrls: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
describe('DELETE /api/user/goodreads-shelves/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'user-1', role: 'user' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 404 when shelf does not exist', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Shelf not found');
|
||||
});
|
||||
|
||||
it('returns 403 when shelf belongs to another user', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
|
||||
|
||||
const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('deletes the shelf and returns success', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.goodreadsShelf.delete.mockResolvedValueOnce({});
|
||||
|
||||
const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.goodreadsShelf.delete).toHaveBeenCalledWith({ where: { id: 'shelf-1' } });
|
||||
});
|
||||
|
||||
it('returns 500 when deletion throws', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.goodreadsShelf.delete.mockRejectedValueOnce(new Error('db error'));
|
||||
|
||||
const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('Failed to delete shelf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/user/goodreads-shelves/[id]', () => {
|
||||
const NEW_RSS = 'https://www.goodreads.com/review/list_rss/99999';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'user-1', role: 'user' },
|
||||
json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 404 when shelf does not exist', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any,
|
||||
{ params: Promise.resolve({ id: 'shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Shelf not found');
|
||||
});
|
||||
|
||||
it('returns 403 when shelf belongs to another user', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any,
|
||||
{ params: Promise.resolve({ id: 'shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('returns 400 for an invalid (non-URL) rssUrl', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ rssUrl: 'not-a-url' }) } as any,
|
||||
{ params: Promise.resolve({ id: 'shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('updates the shelf, clears sync metadata, and triggers a sync job', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
const updatedShelf = { ...SHELF, rssUrl: NEW_RSS, lastSyncAt: null };
|
||||
prismaMock.goodreadsShelf.update.mockResolvedValueOnce(updatedShelf);
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any,
|
||||
{ params: Promise.resolve({ id: 'shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.goodreadsShelf.update).toHaveBeenCalledWith({
|
||||
where: { id: 'shelf-1' },
|
||||
data: { rssUrl: NEW_RSS, lastSyncAt: null, bookCount: null, coverUrls: null },
|
||||
});
|
||||
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0);
|
||||
});
|
||||
|
||||
it('still returns 200 even when the sync job fails to enqueue', async () => {
|
||||
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.goodreadsShelf.update.mockResolvedValueOnce({ ...SHELF, rssUrl: NEW_RSS });
|
||||
jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down'));
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any,
|
||||
{ params: Promise.resolve({ id: 'shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
// Sync job failure is swallowed; shelf update should still succeed
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Component: Hardcover Shelves [id] API Route Tests
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((s: string) => `enc:${s}`),
|
||||
decrypt: vi.fn((s: string) => s.replace('enc:', '')),
|
||||
isEncryptedFormat: vi.fn((s: string) => s.startsWith('enc:')),
|
||||
}));
|
||||
|
||||
const fetchHardcoverListMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/hardcover-api.service', () => ({
|
||||
fetchHardcoverList: fetchHardcoverListMock,
|
||||
}));
|
||||
|
||||
const SHELF = {
|
||||
id: 'hc-shelf-1',
|
||||
userId: 'user-1',
|
||||
name: 'Currently Reading',
|
||||
listId: 'status-2',
|
||||
apiToken: 'enc:secret-token',
|
||||
lastSyncAt: null,
|
||||
bookCount: 3,
|
||||
coverUrls: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
describe('DELETE /api/user/hardcover-shelves/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'user-1', role: 'user' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 404 when list does not exist', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('List not found');
|
||||
});
|
||||
|
||||
it('returns 403 when list belongs to another user', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
|
||||
|
||||
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('deletes the list and returns success', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.hardcoverShelf.delete.mockResolvedValueOnce({});
|
||||
|
||||
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.hardcoverShelf.delete).toHaveBeenCalledWith({ where: { id: 'hc-shelf-1' } });
|
||||
});
|
||||
|
||||
it('returns 500 when deletion throws', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.hardcoverShelf.delete.mockRejectedValueOnce(new Error('db error'));
|
||||
|
||||
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('Failed to delete list');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/user/hardcover-shelves/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'user-1', role: 'user' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
encryptionMock.isEncryptedFormat.mockImplementation((s: string) => s.startsWith('enc:'));
|
||||
encryptionMock.encrypt.mockImplementation((s: string) => `enc:${s}`);
|
||||
encryptionMock.decrypt.mockImplementation((s: string) => s.replace('enc:', ''));
|
||||
fetchHardcoverListMock.mockResolvedValue({ listName: 'Test List', books: [] });
|
||||
});
|
||||
|
||||
it('returns 404 when list does not exist', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
|
||||
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('List not found');
|
||||
});
|
||||
|
||||
it('returns 403 when list belongs to another user', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
|
||||
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('does not trigger a sync when no fields changed', async () => {
|
||||
// listId is the same as existing; no apiToken provided
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ listId: SHELF.listId }) } as any,
|
||||
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates listId, clears metadata, and triggers a sync job', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null };
|
||||
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(updated);
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
|
||||
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({
|
||||
where: { id: 'hc-shelf-1' },
|
||||
data: expect.objectContaining({ listId: 'status-3', lastSyncAt: null }),
|
||||
});
|
||||
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0);
|
||||
});
|
||||
|
||||
it('encrypts the apiToken before persisting', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ apiToken: 'my-raw-token' }) } as any,
|
||||
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
|
||||
);
|
||||
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token');
|
||||
expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({
|
||||
where: { id: 'hc-shelf-1' },
|
||||
data: expect.objectContaining({ apiToken: 'enc:my-raw-token' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('strips the Bearer prefix before encrypting the token', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ apiToken: 'Bearer my-raw-token' }) } as any,
|
||||
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
|
||||
);
|
||||
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token');
|
||||
});
|
||||
|
||||
it('still returns 200 even when the sync job fails to enqueue', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||
prismaMock.hardcoverShelf.update.mockResolvedValueOnce({ ...SHELF, listId: 'status-3' });
|
||||
jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down'));
|
||||
|
||||
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
|
||||
const response = await PATCH(
|
||||
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
|
||||
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Component: Hardcover Shelves API Route Tests (POST / GET)
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((s: string) => `enc:${s}`),
|
||||
decrypt: vi.fn((s: string) => s.replace('enc:', '')),
|
||||
}));
|
||||
const fetchHardcoverListMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/hardcover-api.service', () => ({
|
||||
fetchHardcoverList: fetchHardcoverListMock,
|
||||
}));
|
||||
|
||||
const FETCHED_LIST = {
|
||||
listName: 'Currently Reading',
|
||||
books: [
|
||||
{ title: 'Dune', author: 'Frank Herbert', coverUrl: 'https://example.com/dune.jpg' },
|
||||
{ title: 'Foundation', author: 'Isaac Asimov', coverUrl: null },
|
||||
],
|
||||
};
|
||||
|
||||
describe('POST /api/user/hardcover-shelves', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'user-1', role: 'user' },
|
||||
json: vi.fn().mockResolvedValue({ listId: 'status-2', apiToken: 'raw-token' }),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 400 when apiToken is missing', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({ listId: 'status-2' });
|
||||
|
||||
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 400 when listId is missing', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({ apiToken: 'raw-token' });
|
||||
|
||||
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 409 when the list is already subscribed', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ id: 'existing-shelf' });
|
||||
|
||||
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(payload.error).toBe('DuplicateShelf');
|
||||
});
|
||||
|
||||
it('returns 400 when Hardcover API fetch fails', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
|
||||
fetchHardcoverListMock.mockRejectedValueOnce(new Error('Invalid token'));
|
||||
|
||||
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('InvalidHardcoverList');
|
||||
expect(payload.message).toContain('Invalid token');
|
||||
});
|
||||
|
||||
it('creates the shelf with an encrypted token and triggers sync', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
|
||||
fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST);
|
||||
prismaMock.hardcoverShelf.create.mockResolvedValueOnce({
|
||||
id: 'new-shelf',
|
||||
name: 'Currently Reading',
|
||||
listId: 'status-2',
|
||||
lastSyncAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
bookCount: 2,
|
||||
coverUrls: null,
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.shelf.name).toBe('Currently Reading');
|
||||
|
||||
// Token must have been encrypted before storage
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token');
|
||||
expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
apiToken: 'enc:raw-token',
|
||||
listId: 'status-2',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Immediate background sync must have been triggered
|
||||
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0);
|
||||
});
|
||||
|
||||
it('strips Bearer prefix from apiToken before encrypting', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({ listId: 'status-2', apiToken: 'Bearer raw-token' });
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
|
||||
fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST);
|
||||
prismaMock.hardcoverShelf.create.mockResolvedValueOnce({
|
||||
id: 'new-shelf-2',
|
||||
name: 'Currently Reading',
|
||||
listId: 'status-2',
|
||||
lastSyncAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
bookCount: 2,
|
||||
coverUrls: null,
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
|
||||
await POST({} as any);
|
||||
|
||||
// "Bearer " prefix must have been stripped before encrypt was called
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token');
|
||||
});
|
||||
|
||||
it('returns 201 even when the sync job fails to enqueue', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
|
||||
fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST);
|
||||
prismaMock.hardcoverShelf.create.mockResolvedValueOnce({
|
||||
id: 'new-shelf-3',
|
||||
name: 'Currently Reading',
|
||||
listId: 'status-2',
|
||||
lastSyncAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
bookCount: 2,
|
||||
coverUrls: null,
|
||||
});
|
||||
jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down'));
|
||||
|
||||
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('only includes books with cover URLs in the initial shelf preview', async () => {
|
||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
|
||||
fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); // only 1 of 2 books has coverUrl
|
||||
prismaMock.hardcoverShelf.create.mockResolvedValueOnce({
|
||||
id: 'new-shelf-4',
|
||||
name: 'Currently Reading',
|
||||
listId: 'status-2',
|
||||
lastSyncAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
bookCount: 2,
|
||||
coverUrls: null,
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
// The coverUrls stored should only include books with non-null coverUrl
|
||||
expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
// 1 book has cover, 1 doesn't → only 1 stored
|
||||
coverUrls: JSON.stringify([
|
||||
{ coverUrl: 'https://example.com/dune.jpg', asin: null, title: 'Dune', author: 'Frank Herbert' },
|
||||
]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Component: Home Sections API Route Tests
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: vi.fn((_req: any, handler: any) => {
|
||||
const mockReq = {
|
||||
user: { id: 'user-1', sub: 'user-1', role: 'user' },
|
||||
json: async () => (globalThis as any).__testBody || {},
|
||||
};
|
||||
return handler(mockReq);
|
||||
}),
|
||||
getCurrentUser: vi.fn(() => ({ sub: 'user-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: { create: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }) },
|
||||
}));
|
||||
|
||||
describe('GET /api/user/home-sections', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Re-apply default mock implementations after clearAllMocks
|
||||
prismaMock.userHomeSection.createMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.userHomeSection.deleteMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));
|
||||
});
|
||||
|
||||
it('returns default sections for new user', async () => {
|
||||
// ensureDefaultSections check: no existing sections
|
||||
prismaMock.userHomeSection.findMany
|
||||
.mockResolvedValueOnce([]) // ensureDefaultSections
|
||||
.mockResolvedValueOnce([ // actual fetch after defaults created
|
||||
{ id: '1', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 0 },
|
||||
{ id: '2', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 1 },
|
||||
]);
|
||||
prismaMock.scheduledJob.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const { GET } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections');
|
||||
const response = await GET(request as any);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sections).toHaveLength(2);
|
||||
expect(data.sections[0].sectionType).toBe('popular');
|
||||
expect(data.sections[1].sectionType).toBe('new_releases');
|
||||
});
|
||||
|
||||
it('returns existing sections without creating defaults', async () => {
|
||||
prismaMock.userHomeSection.findMany
|
||||
.mockResolvedValueOnce([{ id: '1' }]) // has existing
|
||||
.mockResolvedValueOnce([
|
||||
{ id: '1', sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 0 },
|
||||
]);
|
||||
prismaMock.scheduledJob.findFirst.mockResolvedValueOnce({
|
||||
nextRun: new Date('2026-03-05T00:00:00Z'),
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections');
|
||||
const response = await GET(request as any);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sections).toHaveLength(1);
|
||||
expect(data.sections[0].categoryName).toBe('Sci-Fi');
|
||||
expect(data.nextRefresh).toBe('2026-03-05T00:00:00.000Z');
|
||||
expect(prismaMock.userHomeSection.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/user/home-sections', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.userHomeSection.createMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.userHomeSection.deleteMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));
|
||||
});
|
||||
|
||||
it('saves new section configuration', async () => {
|
||||
(globalThis as any).__testBody = {
|
||||
sections: [
|
||||
{ sectionType: 'new_releases', sortOrder: 0 },
|
||||
{ sectionType: 'popular', sortOrder: 1 },
|
||||
{ sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
prismaMock.userHomeSection.findMany.mockResolvedValueOnce([
|
||||
{ id: '1', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 0 },
|
||||
{ id: '2', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 1 },
|
||||
{ id: '3', sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 2 },
|
||||
]);
|
||||
|
||||
const { PUT } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
|
||||
const response = await PUT(request as any);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sections).toHaveLength(3);
|
||||
expect(prismaMock.userHomeSection.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
});
|
||||
expect(prismaMock.userHomeSection.createMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects more than 10 sections', async () => {
|
||||
(globalThis as any).__testBody = {
|
||||
sections: Array.from({ length: 11 }, (_, i) => ({
|
||||
sectionType: 'category',
|
||||
categoryId: `cat-${i}`,
|
||||
categoryName: `Cat ${i}`,
|
||||
sortOrder: i,
|
||||
})),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
|
||||
const response = await PUT(request as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects duplicate sections', async () => {
|
||||
(globalThis as any).__testBody = {
|
||||
sections: [
|
||||
{ sectionType: 'popular', sortOrder: 0 },
|
||||
{ sectionType: 'popular', sortOrder: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
|
||||
const response = await PUT(request as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toContain('Duplicate');
|
||||
});
|
||||
|
||||
it('rejects category section without categoryId', async () => {
|
||||
(globalThis as any).__testBody = {
|
||||
sections: [{ sectionType: 'category', sortOrder: 0 }],
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
|
||||
const response = await PUT(request as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toContain('categoryId');
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,7 @@ describe('RequestActionsDropdown', () => {
|
||||
author: 'Author',
|
||||
status: 'downloaded',
|
||||
type: 'ebook',
|
||||
torrentUrl: JSON.stringify(['https://annas-archive.li/slow_download/abc123def456abc123def456abc123de/0/5']),
|
||||
torrentUrl: JSON.stringify(['https://annas-archive.gl/slow_download/abc123def456abc123def456abc123de/0/5']),
|
||||
}}
|
||||
onManualSearch={vi.fn().mockResolvedValue(undefined)}
|
||||
onCancel={vi.fn().mockResolvedValue(undefined)}
|
||||
|
||||
@@ -28,7 +28,7 @@ const renderHook = <T,>(hook: () => T) => {
|
||||
const baseEbook = {
|
||||
enabled: true,
|
||||
preferredFormat: 'epub',
|
||||
baseUrl: 'https://annas-archive.li',
|
||||
baseUrl: 'https://annas-archive.gl',
|
||||
flaresolverrUrl: 'http://flare',
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('useEbookSettings', () => {
|
||||
expect(result.current.flaresolverrTestResult?.success).toBe(true);
|
||||
// Verify baseUrl is included in the request body
|
||||
const callBody = JSON.parse(fetchWithAuthMock.mock.calls[0][1].body);
|
||||
expect(callBody.baseUrl).toBe('https://annas-archive.li');
|
||||
expect(callBody.baseUrl).toBe('https://annas-archive.gl');
|
||||
expect(callBody.url).toBe('http://flare');
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user