mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-13 09:40:11 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ae8f66a2d | |||
| 09cff5b68d | |||
| da7ad7cac1 | |||
| 8aac63715a | |||
| 0a405f2313 | |||
| 98c89db0a7 | |||
| 309a7960a8 | |||
| 06e77b8eba | |||
| dfc34df3d1 | |||
| 5d2e33e369 | |||
| 789a2e50ef | |||
| 9cb9d06144 | |||
| a81549768c | |||
| c0cff56b47 | |||
| e2ae4c7eef | |||
| a564fefd7c | |||
| 01b59fae9d | |||
| 137e2b5607 | |||
| 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
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
|
||||
|
||||
**NEVER implement without approval.** When asked to assess, investigate, or fix a problem:
|
||||
1. **Research & analyze** — Read code, trace the issue, identify root cause.
|
||||
2. **Present a solution plan** — Explain the root cause, list the specific files and changes needed, and describe the approach clearly.
|
||||
3. **Wait for explicit approval** — Do NOT write any code until the user confirms the plan.
|
||||
4. Only after approval: implement, build, and report results.
|
||||
|
||||
This applies to bug fixes, feature requests, and any code changes. Investigation and analysis are always fine — writing code is not until approved.
|
||||
|
||||
---
|
||||
|
||||
## 1. Token-Efficient Documentation System
|
||||
|
||||
@@ -49,6 +49,15 @@ services:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
|
||||
# ========================================================================
|
||||
# OPTIONAL: File Permission Mask
|
||||
# ========================================================================
|
||||
# Set a umask to control default file permissions for all files created
|
||||
# by the application. Common values:
|
||||
# - 002: Group-writable (files: 664, dirs: 775) - recommended for shared access
|
||||
# - 022: Group-readable only (files: 644, dirs: 755) - more restrictive
|
||||
# UMASK: "002"
|
||||
|
||||
# ========================================================================
|
||||
# OPTIONAL: Secrets (auto-generated on first run if not provided)
|
||||
# ========================================================================
|
||||
|
||||
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
|
||||
echo "[App] Starting Next.js server..."
|
||||
echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
||||
|
||||
# Apply UMASK if set (controls default file permissions)
|
||||
if [ -n "$UMASK" ]; then
|
||||
echo "[App] Applying umask: $UMASK"
|
||||
umask "$UMASK"
|
||||
fi
|
||||
|
||||
cd /app
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -387,6 +387,7 @@ PORT=$PORT
|
||||
HOSTNAME=$HOSTNAME
|
||||
PUID=${PUID:-}
|
||||
PGID=${PGID:-}
|
||||
UMASK=${UMASK:-}
|
||||
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
||||
EOF
|
||||
|
||||
@@ -403,6 +404,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..."
|
||||
|
||||
+13
-1
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
|
||||
supervisor \
|
||||
curl \
|
||||
openssl \
|
||||
ffmpeg \
|
||||
locales \
|
||||
gosu \
|
||||
xz-utils \
|
||||
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
|
||||
&& locale-gen en_US.UTF-8 \
|
||||
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install static ffmpeg (no transitive dependencies like imagemagick)
|
||||
ADD https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz /tmp/ffmpeg.tar.xz
|
||||
RUN cd /tmp && tar xf ffmpeg.tar.xz && \
|
||||
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||
rm -rf /tmp/ffmpeg*
|
||||
|
||||
# Remove imagemagick (pre-installed in node:20-bookworm base image, not needed)
|
||||
RUN apt-get purge -y imagemagick imagemagick-6-common 'imagemagick-6*' \
|
||||
'libmagickcore*' 'libmagickwand*' && \
|
||||
apt-get autoremove -y --purge && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
|
||||
@@ -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.5",
|
||||
"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";
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ignored_audiobooks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"asin" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"cover_art_url" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ignored_audiobooks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ignored_audiobooks_user_id_idx" ON "ignored_audiobooks"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ignored_audiobooks_asin_idx" ON "ignored_audiobooks"("asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ignored_audiobooks_user_id_asin_key" ON "ignored_audiobooks"("user_id", "asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ignored_audiobooks" ADD CONSTRAINT "ignored_audiobooks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+138
-26
@@ -66,12 +66,15 @@ 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[]
|
||||
ignoredAudiobooks IgnoredAudiobook[]
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -98,12 +101,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 +108,6 @@ model AudibleCache {
|
||||
@@index([asin])
|
||||
@@index([title])
|
||||
@@index([author])
|
||||
@@index([isPopular])
|
||||
@@index([isNewRelease])
|
||||
@@index([popularRank])
|
||||
@@index([newReleaseRank])
|
||||
@@map("audible_cache")
|
||||
}
|
||||
|
||||
@@ -535,9 +528,10 @@ model GoodreadsShelf {
|
||||
rssUrl String @map("rss_url") @db.Text
|
||||
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")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -547,21 +541,55 @@ 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
|
||||
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||
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 +675,87 @@ model WatchedAuthor {
|
||||
@@index([authorAsin])
|
||||
@@map("watched_authors")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IGNORED AUDIOBOOK TABLE
|
||||
// Per-user ignore list for auto-request suppression.
|
||||
// Stores the ASIN the user clicked ignore on; works-system expansion
|
||||
// happens at check-time in request-creator.service.ts.
|
||||
// Documentation: documentation/features/ignored-audiobooks.md
|
||||
// ============================================================================
|
||||
|
||||
model IgnoredAudiobook {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
asin String // Audible ASIN that was explicitly ignored
|
||||
title String // Display only — snapshot at ignore time
|
||||
author String // Display only — snapshot at ignore time
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, asin])
|
||||
@@index([userId])
|
||||
@@index([asin])
|
||||
@@map("ignored_audiobooks")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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,12 +62,11 @@ 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 canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||
@@ -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 */}
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface PathsSettings {
|
||||
chapterMergingEnabled: boolean;
|
||||
fileRenameEnabled: boolean;
|
||||
fileRenameTemplate?: string;
|
||||
fileChmod?: string;
|
||||
dirChmod?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -439,6 +439,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Permissions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
File Permissions
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Octal permissions applied when organizing files into the media library. These may be further restricted by the container's UMASK setting.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
File Permissions
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.fileChmod || '664'}
|
||||
onChange={(e) => updatePath('fileChmod', e.target.value)}
|
||||
placeholder="664"
|
||||
className={`font-mono max-w-32 ${paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||
/>
|
||||
{paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
e.g. 664 = owner/group read-write, others read
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Directory Permissions
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.dirChmod || '775'}
|
||||
onChange={(e) => updatePath('dirChmod', e.target.value)}
|
||||
placeholder="775"
|
||||
className={`font-mono max-w-32 ${paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||
/>
|
||||
{paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
e.g. 775 = owner/group full access, others read-execute
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Paths Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,15 +100,21 @@ export async function PATCH(
|
||||
},
|
||||
});
|
||||
|
||||
// Queue search job
|
||||
// Queue search job based on request type
|
||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(id, {
|
||||
const audiobookData = {
|
||||
id: existingRequest.audiobook.id,
|
||||
title: existingRequest.audiobook.title,
|
||||
author: existingRequest.audiobook.author,
|
||||
asin: existingRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (existingRequest.type === 'ebook') {
|
||||
await jobQueue.addSearchEbookJob(id, audiobookData);
|
||||
} else {
|
||||
await jobQueue.addSearchJob(id, audiobookData);
|
||||
}
|
||||
|
||||
searchTriggered = true;
|
||||
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate } = await request.json();
|
||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
|
||||
|
||||
if (!downloadDir || !mediaDir) {
|
||||
return NextResponse.json(
|
||||
@@ -32,6 +32,21 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate octal permission strings (3-4 digits, each 0-7)
|
||||
const octalRegex = /^[0-7]{3,4}$/;
|
||||
if (fileChmod !== undefined && !octalRegex.test(fileChmod)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (dirChmod !== undefined && !octalRegex.test(dirChmod)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_dir' },
|
||||
@@ -123,6 +138,34 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Update file permissions (octal chmod)
|
||||
if (fileChmod !== undefined) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'file_chmod' },
|
||||
update: { value: fileChmod },
|
||||
create: {
|
||||
key: 'file_chmod',
|
||||
value: fileChmod,
|
||||
category: 'automation',
|
||||
description: 'Octal permissions applied to organized files',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update directory permissions (octal chmod)
|
||||
if (dirChmod !== undefined) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'dir_chmod' },
|
||||
update: { value: dirChmod },
|
||||
create: {
|
||||
key: 'dir_chmod',
|
||||
value: dirChmod,
|
||||
category: 'automation',
|
||||
description: 'Octal permissions applied to created directories',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Paths settings updated');
|
||||
|
||||
// Clear config cache for all updated keys so services get fresh values
|
||||
@@ -135,6 +178,8 @@ export async function PUT(request: NextRequest) {
|
||||
configService.clearCache('chapter_merging_enabled');
|
||||
configService.clearCache('file_rename_enabled');
|
||||
configService.clearCache('file_rename_template');
|
||||
configService.clearCache('file_chmod');
|
||||
configService.clearCache('dir_chmod');
|
||||
|
||||
// Invalidate all download client singletons to force reload of download_dir
|
||||
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
|
||||
@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
|
||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
||||
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
||||
fileChmod: configMap.get('file_chmod') || '664',
|
||||
dirChmod: configMap.get('dir_chmod') || '775',
|
||||
},
|
||||
ebook: {
|
||||
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
||||
@@ -138,7 +140,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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -260,6 +260,7 @@ export async function POST(
|
||||
parentRequestId: availableRequest?.id || null, // Link to parent if exists
|
||||
status: 'awaiting_approval',
|
||||
progress: 0,
|
||||
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -292,6 +293,7 @@ export async function POST(
|
||||
parentRequestId: availableRequest?.id || null,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -252,6 +252,7 @@ export async function POST(
|
||||
status: 'awaiting_approval',
|
||||
progress: 0,
|
||||
selectedTorrent: selectedEbook as any,
|
||||
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||
},
|
||||
});
|
||||
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
|
||||
@@ -296,6 +297,7 @@ export async function POST(
|
||||
parentRequestId: availableRequest?.id || null,
|
||||
status: 'searching',
|
||||
progress: 0,
|
||||
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||
},
|
||||
});
|
||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
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);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
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,14 @@ 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 { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
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 +49,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 +80,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;
|
||||
@@ -126,18 +137,21 @@ export async function GET(request: NextRequest) {
|
||||
// Enrich with real-time Plex library matching and request status
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
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,14 @@ 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 { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
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 +49,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 +80,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;
|
||||
@@ -126,18 +137,21 @@ export async function GET(request: NextRequest) {
|
||||
// Enrich with real-time Plex library matching and request status
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
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) });
|
||||
|
||||
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Search');
|
||||
|
||||
@@ -51,10 +52,13 @@ export async function GET(request: NextRequest) {
|
||||
// Enrich search results with availability and request status information
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
query: results.query,
|
||||
results: enrichedResults,
|
||||
results: annotatedResults,
|
||||
totalResults: enrichedResults.length,
|
||||
page: results.page,
|
||||
hasMore: results.hasMore,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Authors.Books');
|
||||
|
||||
@@ -67,11 +68,14 @@ export async function GET(
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
|
||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||
|
||||
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
books: enrichedBooks,
|
||||
books: annotatedBooks,
|
||||
authorName: authorName.trim(),
|
||||
authorAsin: asin,
|
||||
totalBooks: enrichedBooks.length,
|
||||
|
||||
@@ -123,6 +123,7 @@ export async function POST(
|
||||
parentRequestId,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
customSearchTerms: parentRequest.customSearchTerms,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -52,17 +52,32 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the parent audiobook request
|
||||
const parentRequest = await prisma.request.findUnique({
|
||||
// Get the request - could be an audiobook request or an existing ebook request
|
||||
const foundRequest = await prisma.request.findUnique({
|
||||
where: { id: parentRequestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!parentRequest) {
|
||||
if (!foundRequest) {
|
||||
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parentRequest.type !== 'audiobook') {
|
||||
// If this is an ebook request, find the parent audiobook request
|
||||
let parentRequest;
|
||||
if (foundRequest.type === 'ebook') {
|
||||
if (!foundRequest.parentRequestId) {
|
||||
return NextResponse.json({ error: 'Ebook request has no parent audiobook request' }, { status: 400 });
|
||||
}
|
||||
parentRequest = await prisma.request.findUnique({
|
||||
where: { id: foundRequest.parentRequestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
if (!parentRequest) {
|
||||
return NextResponse.json({ error: 'Parent audiobook request not found' }, { status: 404 });
|
||||
}
|
||||
} else if (foundRequest.type === 'audiobook') {
|
||||
parentRequest = foundRequest;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -74,13 +89,16 @@ export async function POST(
|
||||
}
|
||||
|
||||
// Check for existing ebook request
|
||||
let ebookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
// If we were given an ebook request ID directly, use that; otherwise search by parent
|
||||
let ebookRequest = foundRequest.type === 'ebook'
|
||||
? foundRequest
|
||||
: await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId: parentRequest.id,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
@@ -109,9 +127,10 @@ export async function POST(
|
||||
userId: parentRequest.userId,
|
||||
audiobookId: parentRequest.audiobookId,
|
||||
type: 'ebook',
|
||||
parentRequestId,
|
||||
parentRequestId: parentRequest.id,
|
||||
status: 'searching',
|
||||
progress: 0,
|
||||
customSearchTerms: parentRequest.customSearchTerms,
|
||||
},
|
||||
});
|
||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
}, { skipAutoSearch });
|
||||
}, { skipAutoSearch, bypassIgnore: true });
|
||||
|
||||
if (!result.success) {
|
||||
const statusMap: Record<string, { error: string; status: number }> = {
|
||||
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
being_processed: { error: 'BeingProcessed', status: 409 },
|
||||
duplicate: { error: 'DuplicateRequest', status: 409 },
|
||||
user_not_found: { error: 'UserNotFound', status: 404 },
|
||||
ignored: { error: 'Ignored', status: 409 },
|
||||
};
|
||||
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
||||
return NextResponse.json(
|
||||
@@ -97,9 +98,27 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Status groups for server-side filtering and count aggregation
|
||||
const STATUS_GROUPS: Record<string, string[]> = {
|
||||
active: ['pending', 'searching', 'downloading', 'processing'],
|
||||
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'],
|
||||
completed: ['available', 'downloaded'],
|
||||
failed: ['failed'],
|
||||
cancelled: ['cancelled', 'denied'],
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/requests?status=pending&limit=50
|
||||
* Get user's audiobook requests (or all requests for admins)
|
||||
* GET /api/requests
|
||||
* Get user's audiobook requests with cursor-based pagination and accurate counts.
|
||||
*
|
||||
* Query params:
|
||||
* status - filter group: 'active'|'waiting'|'completed'|'failed'|'cancelled'|specific status
|
||||
* cursor - request ID for cursor-based pagination (exclusive start)
|
||||
* take - page size (default 20, max 100)
|
||||
* myOnly - 'true' to restrict to current user even for admins
|
||||
* type - 'audiobook'|'ebook'
|
||||
*
|
||||
* Response: { requests, nextCursor, counts: { all, active, waiting, completed, failed, cancelled } }
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
@@ -112,61 +131,102 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const status = searchParams.get('status');
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const statusParam = searchParams.get('status');
|
||||
const cursor = searchParams.get('cursor');
|
||||
const take = Math.min(parseInt(searchParams.get('take') || '20', 10), 100);
|
||||
// Legacy support: honour `limit` if `take` not supplied
|
||||
const limit = searchParams.has('take')
|
||||
? take
|
||||
: Math.min(parseInt(searchParams.get('limit') || '20', 10), 100);
|
||||
const myOnly = searchParams.get('myOnly') === 'true';
|
||||
const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all
|
||||
const type = searchParams.get('type');
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
|
||||
// Build query
|
||||
// If myOnly=true, always filter by current user (even for admins)
|
||||
// Otherwise, admins see all requests, users see only their own
|
||||
const where: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
// Filter by type if specified (otherwise returns all types)
|
||||
if (type && ['audiobook', 'ebook'].includes(type)) {
|
||||
where.type = type;
|
||||
}
|
||||
// Only show active (non-deleted) requests
|
||||
where.deletedAt = null;
|
||||
// Base ownership filter
|
||||
const baseWhere: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
|
||||
baseWhere.deletedAt = null;
|
||||
|
||||
if (type && ['audiobook', 'ebook'].includes(type)) {
|
||||
baseWhere.type = type;
|
||||
}
|
||||
|
||||
// Resolve status filter
|
||||
const statusFilter: any = {};
|
||||
if (statusParam) {
|
||||
const group = STATUS_GROUPS[statusParam];
|
||||
if (group) {
|
||||
statusFilter.status = { in: group };
|
||||
} else {
|
||||
// Treat as a specific status literal
|
||||
statusFilter.status = statusParam;
|
||||
}
|
||||
}
|
||||
|
||||
const where = { ...baseWhere, ...statusFilter };
|
||||
|
||||
// ── Paginated request fetch ──────────────────────────────────────────
|
||||
const requests = await prisma.request.findMany({
|
||||
where,
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
select: { id: true, plexUsername: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
take: limit + 1, // fetch one extra to determine if there's a next page
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const enriched = requests.map(r => {
|
||||
const hasNextPage = requests.length > limit;
|
||||
const page = hasNextPage ? requests.slice(0, limit) : requests;
|
||||
const nextCursor = hasNextPage ? page[page.length - 1].id : null;
|
||||
|
||||
const enriched = page.map(r => {
|
||||
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
|
||||
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
|
||||
// Strip server-side absolute path from client response
|
||||
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
|
||||
return { ...r, audiobook, downloadAvailable };
|
||||
});
|
||||
|
||||
// ── Accurate counts per group (always scoped to ownership/type filter) ──
|
||||
const countWhere = { ...baseWhere };
|
||||
|
||||
const [
|
||||
totalAll,
|
||||
totalActive,
|
||||
totalWaiting,
|
||||
totalCompleted,
|
||||
totalFailed,
|
||||
totalCancelled,
|
||||
] = await Promise.all([
|
||||
prisma.request.count({ where: countWhere }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.active } } }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.waiting } } }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.completed } } }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.failed } } }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.cancelled } } }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requests: enriched,
|
||||
nextCursor,
|
||||
counts: {
|
||||
all: totalAll,
|
||||
active: totalActive,
|
||||
waiting: totalWaiting,
|
||||
completed: totalCompleted,
|
||||
failed: totalFailed,
|
||||
cancelled: totalCancelled,
|
||||
},
|
||||
// Legacy field for callers that still read `count`
|
||||
count: enriched.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch requests',
|
||||
},
|
||||
{ error: 'FetchError', message: 'Failed to fetch requests' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Detail');
|
||||
|
||||
@@ -63,13 +64,16 @@ export async function GET(
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
|
||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||
|
||||
logger.info(`Series detail complete: "${detail.title}" (${annotatedBooks.length} books, page ${page})`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series: {
|
||||
...detail,
|
||||
books: enrichedBooks,
|
||||
books: annotatedBooks,
|
||||
},
|
||||
hasMore: detail.hasMore,
|
||||
page: detail.page,
|
||||
|
||||
@@ -7,9 +7,16 @@ 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').optional(),
|
||||
autoRequest: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/user/goodreads-shelves/[id]
|
||||
* Remove a Goodreads shelf subscription (ownership check)
|
||||
@@ -48,3 +55,73 @@ 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, autoRequest } = UpdateGoodreadsSchema.parse(body);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
let needsResync = false;
|
||||
|
||||
if (rssUrl !== undefined) {
|
||||
updateData.rssUrl = rssUrl;
|
||||
updateData.lastSyncAt = null;
|
||||
updateData.bookCount = null;
|
||||
updateData.coverUrls = null;
|
||||
needsResync = true;
|
||||
}
|
||||
|
||||
if (autoRequest !== undefined) {
|
||||
updateData.autoRequest = autoRequest;
|
||||
}
|
||||
|
||||
const updated = await prisma.goodreadsShelf.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
if (needsResync) {
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0, req.user.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: 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const AddShelfSchema = z.object({
|
||||
(url) => GOODREADS_RSS_PATTERN.test(url),
|
||||
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
||||
),
|
||||
autoRequest: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -66,6 +67,7 @@ export async function GET(request: NextRequest) {
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount ?? null,
|
||||
autoRequest: shelf.autoRequest,
|
||||
books,
|
||||
};
|
||||
});
|
||||
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { rssUrl } = AddShelfSchema.parse(body);
|
||||
const { rssUrl, autoRequest } = AddShelfSchema.parse(body);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.goodreadsShelf.findUnique({
|
||||
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
|
||||
name: shelfName,
|
||||
rssUrl,
|
||||
bookCount,
|
||||
autoRequest,
|
||||
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||
},
|
||||
});
|
||||
@@ -139,8 +142,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, req.user.id);
|
||||
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) });
|
||||
}
|
||||
@@ -154,6 +157,7 @@ export async function POST(request: NextRequest) {
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount,
|
||||
autoRequest: shelf.autoRequest,
|
||||
books: initialBooks,
|
||||
},
|
||||
bookCount,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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(),
|
||||
forceSync: z.boolean().optional(),
|
||||
autoRequest: z.boolean().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, forceSync, autoRequest } = UpdateHardcoverSchema.parse(body);
|
||||
|
||||
const updateData: { listId?: string; apiToken?: string; autoRequest?: boolean; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
||||
|
||||
if (autoRequest !== undefined) {
|
||||
updateData.autoRequest = autoRequest;
|
||||
}
|
||||
let needsResync = !!forceSync;
|
||||
|
||||
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, req.user.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: 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,201 @@
|
||||
/**
|
||||
* 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' }),
|
||||
autoRequest: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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,
|
||||
autoRequest: shelf.autoRequest,
|
||||
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();
|
||||
const parsed = AddShelfSchema.parse(body);
|
||||
let { listId, apiToken } = parsed;
|
||||
const { autoRequest } = parsed;
|
||||
|
||||
// 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,
|
||||
autoRequest,
|
||||
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, req.user.id);
|
||||
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,
|
||||
autoRequest: shelf.autoRequest,
|
||||
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,65 @@
|
||||
/**
|
||||
* Component: Ignored Audiobook Delete Route
|
||||
* Documentation: documentation/features/ignored-audiobooks.md
|
||||
*
|
||||
* DELETE removes a single entry from the user's ignore list (un-ignore).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/ignored-audiobooks/[id]
|
||||
* Remove an audiobook from the user's ignore list
|
||||
*/
|
||||
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;
|
||||
|
||||
// Verify ownership before deleting
|
||||
const existing = await prisma.ignoredAudiobook.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Ignored audiobook entry not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.userId !== req.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden', message: 'Cannot modify another user\'s ignore list' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.ignoredAudiobook.delete({ where: { id } });
|
||||
|
||||
logger.info(`User ${req.user.id} un-ignored ASIN ${existing.asin} ("${existing.title}")`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove ignored audiobook', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'DeleteError', message: 'Failed to remove ignored audiobook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Component: Ignored Audiobook Check Route
|
||||
* Documentation: documentation/features/ignored-audiobooks.md
|
||||
*
|
||||
* Quick check whether a specific ASIN is ignored by the current user.
|
||||
* Includes works-system expansion to catch sibling ASINs.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.IgnoredAudiobooks.Check');
|
||||
|
||||
/**
|
||||
* GET /api/user/ignored-audiobooks/check/[asin]
|
||||
* Returns { ignored: boolean, ignoredId?: string } for the given ASIN.
|
||||
* ignoredId is the ID of the matching IgnoredAudiobook record (for un-ignore).
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
|
||||
// Direct check
|
||||
const directIgnore = await prisma.ignoredAudiobook.findUnique({
|
||||
where: { userId_asin: { userId: req.user.id, asin } },
|
||||
});
|
||||
|
||||
if (directIgnore) {
|
||||
return NextResponse.json({
|
||||
ignored: true,
|
||||
ignoredId: directIgnore.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Works-system expansion: check sibling ASINs
|
||||
try {
|
||||
const siblingMap = await getSiblingAsins([asin]);
|
||||
const siblings = siblingMap.get(asin);
|
||||
if (siblings && siblings.length > 0) {
|
||||
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
asin: { in: siblings },
|
||||
},
|
||||
});
|
||||
if (siblingIgnore) {
|
||||
return NextResponse.json({
|
||||
ignored: true,
|
||||
ignoredId: siblingIgnore.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Works expansion is best-effort
|
||||
}
|
||||
|
||||
return NextResponse.json({ ignored: false });
|
||||
} catch (error) {
|
||||
logger.error('Failed to check ignored status', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'CheckError', message: 'Failed to check ignored status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Component: Ignored Audiobooks API Routes
|
||||
* Documentation: documentation/features/ignored-audiobooks.md
|
||||
*
|
||||
* Per-user ignore list for auto-request suppression.
|
||||
* GET returns the user's full ignore list; POST adds a new entry.
|
||||
*/
|
||||
|
||||
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.IgnoredAudiobooks');
|
||||
|
||||
const AddIgnoredSchema = z.object({
|
||||
asin: z.string().min(1).max(20),
|
||||
title: z.string().min(1).max(500),
|
||||
author: z.string().min(1).max(500),
|
||||
coverArtUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/ignored-audiobooks
|
||||
* List the current user's ignored audiobooks
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ignored = await prisma.ignoredAudiobook.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ignoredAudiobooks: ignored.map((item) => ({
|
||||
id: item.id,
|
||||
asin: item.asin,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
coverArtUrl: item.coverArtUrl,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list ignored audiobooks', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch ignored audiobooks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/ignored-audiobooks
|
||||
* Add an audiobook to the user's ignore list
|
||||
*/
|
||||
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();
|
||||
const data = AddIgnoredSchema.parse(body);
|
||||
|
||||
// Upsert to handle duplicate gracefully
|
||||
const ignored = await prisma.ignoredAudiobook.upsert({
|
||||
where: {
|
||||
userId_asin: { userId: req.user.id, asin: data.asin },
|
||||
},
|
||||
update: {}, // Already exists — no-op
|
||||
create: {
|
||||
userId: req.user.id,
|
||||
asin: data.asin,
|
||||
title: data.title,
|
||||
author: data.author,
|
||||
coverArtUrl: data.coverArtUrl,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ignoredAudiobook: {
|
||||
id: ignored.id,
|
||||
asin: ignored.asin,
|
||||
title: ignored.title,
|
||||
author: ignored.author,
|
||||
coverArtUrl: ignored.coverArtUrl,
|
||||
createdAt: ignored.createdAt.toISOString(),
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to add ignored audiobook', {
|
||||
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: 'CreateError', message: 'Failed to ignore audiobook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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,
|
||||
autoRequest: s.autoRequest,
|
||||
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,
|
||||
autoRequest: s.autoRequest,
|
||||
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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Component: Manual Shelf Sync API Route
|
||||
* 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 { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.ShelvesSync');
|
||||
|
||||
const SyncSchema = z.object({
|
||||
shelfId: z.string().optional(),
|
||||
shelfType: z.enum(['goodreads', 'hardcover']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/user/shelves/sync
|
||||
* Trigger a manual sync for all or a specific shelf belonging to the user.
|
||||
*/
|
||||
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 request.json().catch(() => ({}));
|
||||
const { shelfId, shelfType } = SyncSchema.parse(body);
|
||||
|
||||
// Set lastSyncAt to null so the frontend SWR refresh catches the "Syncing..." state immediately
|
||||
if (!shelfType || shelfType === 'goodreads') {
|
||||
await prisma.goodreadsShelf.updateMany({
|
||||
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
|
||||
data: { lastSyncAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (!shelfType || shelfType === 'hardcover') {
|
||||
await prisma.hardcoverShelf.updateMany({
|
||||
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
|
||||
data: { lastSyncAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Trigger sync job with userId filter
|
||||
await jobQueue.addSyncShelvesJob(
|
||||
undefined,
|
||||
shelfId,
|
||||
shelfType,
|
||||
0, // unlimited lookups for manual trigger
|
||||
req.user.id
|
||||
);
|
||||
|
||||
logger.info(`Manual sync triggered for user ${req.user.id}${shelfId ? ` (shelf: ${shelfId})` : ' (all shelves)'}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: shelfId ? 'Shelf sync triggered' : 'All shelves sync triggered'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
|
||||
}
|
||||
logger.error('Failed to trigger manual sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to trigger manual sync' },
|
||||
{ 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;
|
||||
})()}
|
||||
|
||||
@@ -197,6 +197,23 @@ body {
|
||||
animation: toast-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Requests page list entry animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirmation Dialog */
|
||||
@keyframes dialog-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
|
||||
@@ -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 />
|
||||
|
||||
+344
-160
@@ -1,221 +1,405 @@
|
||||
/**
|
||||
* Component: Requests Page
|
||||
* Component: My Requests Page
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { RequestCard } from '@/components/requests/RequestCard';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { useMyRequests, RequestFilterGroup, RequestCounts } from '@/lib/hooks/useRequests';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
|
||||
// ── Tab configuration ────────────────────────────────────────────────────────
|
||||
|
||||
interface TabOption {
|
||||
value: RequestFilterGroup;
|
||||
label: string;
|
||||
countKey: keyof RequestCounts;
|
||||
}
|
||||
|
||||
const TABS: TabOption[] = [
|
||||
{ value: 'all', label: 'All', countKey: 'all' },
|
||||
{ value: 'active', label: 'Active', countKey: 'active' },
|
||||
{ value: 'waiting', label: 'Waiting', countKey: 'waiting' },
|
||||
{ value: 'completed', label: 'Completed', countKey: 'completed' },
|
||||
{ value: 'failed', label: 'Failed', countKey: 'failed' },
|
||||
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelled' },
|
||||
];
|
||||
|
||||
// ── Count badge ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CountBadge({ count, active }: { count: number; active: boolean }) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-semibold tabular-nums transition-all duration-200',
|
||||
active
|
||||
? 'bg-blue-500/20 text-blue-600 dark:bg-blue-400/20 dark:text-blue-400'
|
||||
: 'bg-gray-200/80 text-gray-500 dark:bg-white/[0.07] dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{count > 999 ? '999+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skeleton card ────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800/60 rounded-xl overflow-hidden border border-gray-100 dark:border-white/[0.06]">
|
||||
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
|
||||
{/* Cover placeholder */}
|
||||
<div className="flex-shrink-0 w-16 sm:w-24 aspect-[2/3] rounded-lg bg-gray-200 dark:bg-white/[0.06] animate-pulse" />
|
||||
{/* Content placeholder */}
|
||||
<div className="flex-1 min-w-0 space-y-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-white/[0.06] rounded-md animate-pulse w-3/4" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-white/[0.06] rounded-md animate-pulse w-1/2" />
|
||||
</div>
|
||||
<div className="h-5 bg-gray-200 dark:bg-white/[0.06] rounded-full animate-pulse w-20" />
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-white/[0.05]">
|
||||
<div className="h-3 bg-gray-200 dark:bg-white/[0.06] rounded animate-pulse w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ filter }: { filter: RequestFilterGroup }) {
|
||||
const isAll = filter === 'all';
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{isAll ? 'No requests yet' : `No ${filter} requests`}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
|
||||
{isAll
|
||||
? 'Start by searching for audiobooks and requesting them'
|
||||
: `You don't have any ${filter} requests right now`}
|
||||
</p>
|
||||
</div>
|
||||
{isAll && (
|
||||
<a
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-medium rounded-xl transition-all duration-150 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
Browse Audiobooks
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Load More button ─────────────────────────────────────────────────────────
|
||||
|
||||
function LoadMoreButton({ onClick, isLoading }: { onClick: () => void; isLoading: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-center pt-2 pb-4">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
|
||||
'border border-gray-200 dark:border-white/[0.1]',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
'bg-white dark:bg-white/[0.04]',
|
||||
'hover:bg-gray-50 dark:hover:bg-white/[0.07]',
|
||||
'active:scale-[0.98]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
|
||||
'shadow-sm'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading more...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Load more
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Live indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
function LiveIndicator({ hasActive }: { hasActive: boolean }) {
|
||||
if (!hasActive) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500" />
|
||||
</span>
|
||||
Live
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab bar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TabBarProps {
|
||||
filter: RequestFilterGroup;
|
||||
counts: RequestCounts;
|
||||
countsLoaded: boolean;
|
||||
onChange: (f: RequestFilterGroup) => void;
|
||||
}
|
||||
|
||||
function TabBar({ filter, counts, countsLoaded, onChange }: TabBarProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll active tab into view on mount/change
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const active = container.querySelector('[data-active="true"]') as HTMLElement | null;
|
||||
if (active) {
|
||||
const { offsetLeft, offsetWidth } = active;
|
||||
const { scrollLeft, clientWidth } = container;
|
||||
if (offsetLeft < scrollLeft || offsetLeft + offsetWidth > scrollLeft + clientWidth) {
|
||||
container.scrollTo({ left: offsetLeft - 16, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
return (
|
||||
<div className="relative -mx-4 sm:mx-0">
|
||||
{/* Left fade */}
|
||||
<div className="pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white dark:from-gray-950 to-transparent z-10 sm:hidden" />
|
||||
{/* Right fade */}
|
||||
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white dark:from-gray-950 to-transparent z-10 sm:hidden" />
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-1 overflow-x-auto scrollbar-hide px-4 sm:px-0"
|
||||
role="tablist"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = filter === tab.value;
|
||||
const count = counts[tab.countKey];
|
||||
// Hide tabs with 0 count unless it's 'all' or currently active
|
||||
if (!isActive && tab.value !== 'all' && countsLoaded && count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
onClick={() => onChange(tab.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none flex-shrink-0',
|
||||
'focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
|
||||
isActive
|
||||
? 'bg-white dark:bg-white/[0.08] text-gray-900 dark:text-white shadow-[0_1px_3px_rgba(0,0,0,0.08),0_1px_6px_rgba(0,0,0,0.05)] dark:shadow-[0_1px_3px_rgba(0,0,0,0.4)] border border-gray-200/80 dark:border-white/[0.1]'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-black/[0.03] dark:hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{countsLoaded
|
||||
? <CountBadge count={count} active={isActive} />
|
||||
: tab.value !== 'all' && (
|
||||
<span className="inline-block w-5 h-3.5 rounded bg-gray-200 dark:bg-white/[0.07] animate-pulse" />
|
||||
)
|
||||
}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Showing count bar ────────────────────────────────────────────────────────
|
||||
|
||||
function ShowingBar({ showing, total, hasActive }: { showing: number; total: number; hasActive: boolean }) {
|
||||
if (showing === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs text-gray-400 dark:text-gray-500 px-0.5">
|
||||
<span>
|
||||
Showing <span className="text-gray-600 dark:text-gray-300 font-medium tabular-nums">{showing}</span>
|
||||
{' of '}
|
||||
<span className="text-gray-600 dark:text-gray-300 font-medium tabular-nums">{total}</span>
|
||||
{total === 1 ? ' request' : ' requests'}
|
||||
</span>
|
||||
<LiveIndicator hasActive={hasActive} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RequestsPage() {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
const [filter, setFilter] = useState<FilterStatus>('all');
|
||||
const [filter, setFilter] = useState<RequestFilterGroup>('all');
|
||||
|
||||
// Always fetch only the current user's requests (even for admins)
|
||||
// This ensures "My Requests" truly shows only the user's own requests
|
||||
// Admins can see all requests in the admin panel
|
||||
const { requests, isLoading } = useRequests(undefined, 50, true);
|
||||
const {
|
||||
requests,
|
||||
counts,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
isEmpty,
|
||||
loadMore,
|
||||
} = useMyRequests(filter);
|
||||
|
||||
// Filter requests client-side based on selected filter
|
||||
const filteredRequests = filter === 'all'
|
||||
? requests
|
||||
: filter === 'active'
|
||||
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status))
|
||||
: filter === 'waiting'
|
||||
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status))
|
||||
: filter === 'completed'
|
||||
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status))
|
||||
: requests.filter((r: any) => r.status === filter);
|
||||
const countsLoaded = !isLoading || requests.length > 0;
|
||||
const totalForFilter = counts[filter === 'all' ? 'all' : filter as keyof RequestCounts] ?? 0;
|
||||
const hasActiveRequests = requests.some(r =>
|
||||
['pending', 'awaiting_search', 'awaiting_approval', 'searching', 'downloading', 'processing', 'awaiting_import'].includes(r.status)
|
||||
);
|
||||
|
||||
const filterOptions: { value: FilterStatus; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'waiting', label: 'Waiting' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
const handleFilterChange = (f: RequestFilterGroup) => {
|
||||
setFilter(f);
|
||||
};
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
// ── Unauthenticated ────────────────────────────────────────────────────────
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Authentication Required
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Please log in to view your audiobook requests
|
||||
</p>
|
||||
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Authentication Required</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please log in to view your audiobook requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Authenticated ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-6 sm:space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="space-y-2 sm:space-y-4">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<main className="container mx-auto px-4 py-6 sm:py-10 max-w-4xl">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50">
|
||||
My Requests
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Track the status of your audiobook requests in real-time
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<div className="flex gap-2 sm:gap-4 -mb-px overflow-x-auto scrollbar-hide">
|
||||
{filterOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setFilter(option.value)}
|
||||
className={cn(
|
||||
'px-3 sm:px-4 py-2 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
|
||||
filter === option.value
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{!isLoading && (
|
||||
<span className="ml-2 text-xs">
|
||||
({option.value === 'all'
|
||||
? requests.length
|
||||
: option.value === 'active'
|
||||
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length
|
||||
: option.value === 'waiting'
|
||||
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length
|
||||
: option.value === 'completed'
|
||||
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length
|
||||
: requests.filter((r: any) => r.status === option.value).length
|
||||
})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
<div className="mb-5">
|
||||
<TabBar
|
||||
filter={filter}
|
||||
counts={counts}
|
||||
countsLoaded={countsLoaded}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{/* Showing bar */}
|
||||
{!isLoading && requests.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<ShowingBar
|
||||
showing={requests.length}
|
||||
total={totalForFilter}
|
||||
hasActive={hasActiveRequests}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state — skeleton cards */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
className="animate-[fadeIn_0.3s_ease-out_both]"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className={cn(
|
||||
'w-24 bg-gray-300 dark:bg-gray-700 rounded',
|
||||
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
|
||||
)}></div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requests List */}
|
||||
{!isLoading && filteredRequests.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{filteredRequests.map((request: any) => (
|
||||
<RequestCard key={request.id} request={request} showActions={true} />
|
||||
{/* Request list */}
|
||||
{!isLoading && requests.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{requests.map((request, i) => (
|
||||
<div
|
||||
key={request.id}
|
||||
style={{ animationDelay: `${Math.min(i, 8) * 40}ms` }}
|
||||
className="animate-[fadeInUp_0.25s_ease-out_both]"
|
||||
>
|
||||
<RequestCard request={request} showActions={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredRequests.length === 0 && (
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{filter === 'all' ? 'No requests yet' : `No ${filter} requests`}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{filter === 'all'
|
||||
? 'Start by searching for audiobooks and requesting them'
|
||||
: `You don't have any ${filter} requests at the moment`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
{filter === 'all' && (
|
||||
<div className="pt-4">
|
||||
<a
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
Search Audiobooks
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* Empty state */}
|
||||
{!isLoading && isEmpty && (
|
||||
<EmptyState filter={filter} />
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!isLoading && hasMore && (
|
||||
<div className="mt-4">
|
||||
<LoadMoreButton onClick={loadMore} isLoading={isLoadingMore} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-refresh indicator */}
|
||||
{!isLoading && filteredRequests.length > 0 && (
|
||||
<div className="text-center text-xs text-gray-500 dark:text-gray-500 py-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Auto-refreshing every 5 seconds</span>
|
||||
</div>
|
||||
{/* Load more skeleton (when fetching additional pages) */}
|
||||
{isLoadingMore && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<SkeletonCard key={`more-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -272,7 +272,7 @@ export function OIDCConfigStep({
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-1 space-y-1">
|
||||
<li>• The redirect URI will be: {typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '[Your Domain]/api/auth/oidc/callback'}</li>
|
||||
<li>• Configure this redirect URI in your OIDC provider settings</li>
|
||||
<li>• Required scopes: openid, profile, email, groups</li>
|
||||
<li>• Required scopes: openid, profile, email (groups is added automatically when group-based access control is enabled)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,8 @@ const getStatusConfig = (audiobook: Audiobook) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
|
||||
|
||||
export function AudiobookCard({
|
||||
audiobook,
|
||||
onRequestSuccess,
|
||||
@@ -57,12 +59,15 @@ export function AudiobookCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [localIsIgnored, setLocalIsIgnored] = useState<boolean | undefined>(undefined);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
|
||||
// Build a display-only audiobook with the local status override
|
||||
// Build a display-only audiobook with local overrides
|
||||
const displayAudiobook = localRequestStatus !== undefined
|
||||
? { ...audiobook, requestStatus: localRequestStatus }
|
||||
: audiobook;
|
||||
const status = getStatusConfig(displayAudiobook);
|
||||
const isIgnored = localIsIgnored !== undefined ? localIsIgnored : audiobook.isIgnored;
|
||||
|
||||
const handleRequest = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -113,20 +118,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
|
||||
@@ -212,6 +220,19 @@ export function AudiobookCard({
|
||||
<span>{audiobook.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ignored Indicator - Bottom Left */}
|
||||
{isIgnored && (
|
||||
<div
|
||||
className="absolute bottom-3 left-3 flex items-center gap-1 px-2 py-1 rounded-lg bg-black/50 backdrop-blur-md text-gray-300 text-xs font-medium transition-opacity duration-300 group-hover:opacity-0"
|
||||
title="Ignored from auto-requests"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
<span>Ignored</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -247,6 +268,7 @@ export function AudiobookCard({
|
||||
onClose={() => setShowModal(false)}
|
||||
onRequestSuccess={onRequestSuccess}
|
||||
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
||||
onIgnoreChange={(ignored) => setLocalIsIgnored(ignored)}
|
||||
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
||||
requestStatus={displayAudiobook.requestStatus}
|
||||
isAvailable={audiobook.isAvailable}
|
||||
|
||||
@@ -19,8 +19,10 @@ import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
||||
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
||||
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
||||
import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
|
||||
|
||||
interface AudiobookDetailsModalProps {
|
||||
asin: string;
|
||||
@@ -28,12 +30,14 @@ interface AudiobookDetailsModalProps {
|
||||
onClose: () => void;
|
||||
onRequestSuccess?: () => void;
|
||||
onStatusChange?: (newStatus: string) => void;
|
||||
onIgnoreChange?: (isIgnored: boolean) => void;
|
||||
isRequested?: boolean;
|
||||
requestStatus?: string | null;
|
||||
isAvailable?: boolean;
|
||||
requestedByUsername?: string | null;
|
||||
hideRequestActions?: boolean;
|
||||
hasReportedIssue?: boolean;
|
||||
aiReason?: string | null;
|
||||
}
|
||||
|
||||
// Status helper
|
||||
@@ -68,12 +72,14 @@ export function AudiobookDetailsModal({
|
||||
onClose,
|
||||
onRequestSuccess,
|
||||
onStatusChange,
|
||||
onIgnoreChange,
|
||||
isRequested = false,
|
||||
requestStatus = null,
|
||||
isAvailable = false,
|
||||
requestedByUsername = null,
|
||||
hideRequestActions = false,
|
||||
hasReportedIssue = false,
|
||||
aiReason = null,
|
||||
}: AudiobookDetailsModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
@@ -83,6 +89,9 @@ export function AudiobookDetailsModal({
|
||||
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||
|
||||
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
|
||||
const { addIgnore, removeIgnore } = useToggleIgnore();
|
||||
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||
@@ -94,6 +103,8 @@ 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);
|
||||
const [isTogglingIgnore, setIsTogglingIgnore] = useState(false);
|
||||
|
||||
// Sync local status when the prop changes (e.g. page data refreshes)
|
||||
useEffect(() => {
|
||||
@@ -193,6 +204,31 @@ export function AudiobookDetailsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleIgnore = async () => {
|
||||
if (!user || !audiobook) return;
|
||||
setIsTogglingIgnore(true);
|
||||
try {
|
||||
if (isIgnored && ignoredId) {
|
||||
await removeIgnore(ignoredId, asin);
|
||||
onIgnoreChange?.(false);
|
||||
showNotification('Removed from ignore list');
|
||||
} else {
|
||||
await addIgnore({
|
||||
asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
});
|
||||
onIgnoreChange?.(true);
|
||||
showNotification('Added to ignore list — auto-requests will skip this book');
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err instanceof Error ? err.message : 'Failed to update ignore status', 'error');
|
||||
} finally {
|
||||
setIsTogglingIgnore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (minutes?: number) => {
|
||||
if (!minutes) return null;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
@@ -285,7 +321,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 +329,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 +494,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">
|
||||
@@ -665,6 +718,26 @@ export function AudiobookDetailsModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ignore Toggle - always visible when user is logged in */}
|
||||
{user && !isLoadingIgnore && (
|
||||
<button
|
||||
onClick={handleToggleIgnore}
|
||||
disabled={isTogglingIgnore}
|
||||
className={`p-3 rounded-xl transition-colors disabled:opacity-50 ${
|
||||
isIgnored
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={isIgnored ? 'Stop Ignoring — auto-requests will resume for this book' : 'Ignore from Auto-Requests'}
|
||||
>
|
||||
{isIgnored ? (
|
||||
<EyeSlashSolidIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<EyeSlashIcon className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
/**
|
||||
* Component: Goodreads 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 { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function GoodreadsShelvesSection() {
|
||||
const { shelves, isLoading } = useGoodreadsShelves();
|
||||
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf();
|
||||
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) => {
|
||||
try {
|
||||
await deleteShelf(shelfId);
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
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" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
|
||||
Goodreads 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
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<ShelfCardSkeleton squareCovers={squareCovers} />
|
||||
) : shelves.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{shelves.map((shelf) => (
|
||||
<ShelfCard
|
||||
key={shelf.id}
|
||||
shelf={shelf}
|
||||
squareCovers={squareCovers}
|
||||
isDeleting={isDeleting && confirmDeleteId === shelf.id}
|
||||
isConfirmingDelete={confirmDeleteId === shelf.id}
|
||||
onDelete={() => handleDelete(shelf.id)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState onAdd={() => setShowAddModal(true)} />
|
||||
)}
|
||||
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
/>
|
||||
|
||||
{/* Audiobook Detail Modal (read-only) */}
|
||||
{selectedAsin && (
|
||||
<AudiobookDetailsModal
|
||||
asin={selectedAsin}
|
||||
isOpen={true}
|
||||
onClose={() => setSelectedAsin(null)}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Empty State ─── */
|
||||
|
||||
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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-200 mb-1.5">
|
||||
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.
|
||||
</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>
|
||||
Add Your First Shelf
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Loading Skeleton ─── */
|
||||
|
||||
function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7">
|
||||
<div className="mb-5">
|
||||
<div className="h-[18px] w-52 bg-gray-100 dark:bg-gray-700/50 rounded-lg animate-pulse mb-2.5" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[22px] w-16 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
|
||||
<div className="h-3.5 w-24 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
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]'
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Shelf Card ─── */
|
||||
|
||||
interface ShelfCardProps {
|
||||
shelf: GoodreadsShelf;
|
||||
squareCovers: boolean;
|
||||
isDeleting: boolean;
|
||||
isConfirmingDelete: boolean;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onBookClick: (asin: string) => void;
|
||||
}
|
||||
|
||||
function ShelfCard({
|
||||
shelf,
|
||||
squareCovers,
|
||||
isDeleting,
|
||||
isConfirmingDelete,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
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 isSyncing = !shelf.lastSyncAt;
|
||||
|
||||
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="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
|
||||
{shelf.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{shelf.bookCount != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
Syncing…
|
||||
</>
|
||||
) : shelf.lastSyncAt ? (
|
||||
<>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
Synced {formatRelativeTime(shelf.lastSyncAt)}
|
||||
</>
|
||||
) : (
|
||||
'Pending sync'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete action */}
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
{isConfirmingDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-3 py-1.5 text-xs font-semibold text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Removing\u2026' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Stacked book covers */}
|
||||
{hasCovers ? (
|
||||
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
|
||||
) : isSyncing ? (
|
||||
<div className="flex items-end">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
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]'
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Stacked Cover Display ─── */
|
||||
|
||||
function CoverStack({
|
||||
books,
|
||||
remainingCount,
|
||||
squareCovers,
|
||||
onBookClick,
|
||||
}: {
|
||||
books: ShelfBook[];
|
||||
remainingCount: number;
|
||||
squareCovers: boolean;
|
||||
onBookClick: (asin: string) => void;
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const coverSize = squareCovers
|
||||
? 'w-[80px] aspect-square'
|
||||
: 'w-[72px] aspect-[2/3]';
|
||||
|
||||
return (
|
||||
<div className="flex items-end">
|
||||
{books.map((book, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'relative rounded-xl overflow-hidden shadow-md flex-shrink-0',
|
||||
'ring-2 ring-white dark:ring-gray-800',
|
||||
'transition-all duration-300 ease-out',
|
||||
hoveredIndex === i && 'scale-[1.18] shadow-xl',
|
||||
coverSize,
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
style={{
|
||||
marginLeft: i > 0 ? '-16px' : 0,
|
||||
zIndex: hoveredIndex === i ? 50 : books.length - i,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(i)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => book.asin && onBookClick(book.asin)}
|
||||
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
|
||||
>
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<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
|
||||
)}
|
||||
style={{ marginLeft: '-16px', zIndex: 0 }}
|
||||
>
|
||||
<span className="text-sm font-semibold text-gray-400 dark:text-gray-500 tabular-nums">
|
||||
+{remainingCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Component: Combined Shelves Section (Profile Page)
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
useShelves,
|
||||
GenericShelf,
|
||||
useSyncShelves,
|
||||
} from '@/lib/hooks/useShelves';
|
||||
import { useDeleteGoodreadsShelf, useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useDeleteHardcoverShelf, useUpdateHardcoverShelf } 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';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function ShelvesSection() {
|
||||
const { shelves, isLoading } = useShelves();
|
||||
const { deleteShelf: deleteGoodreads, isLoading: isDeletingGoodreads } =
|
||||
useDeleteGoodreadsShelf();
|
||||
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
||||
useDeleteHardcoverShelf();
|
||||
const { syncShelves, isSyncing: isSyncingAll } = useSyncShelves();
|
||||
const { updateShelf: updateGoodreads } = useUpdateGoodreadsShelf();
|
||||
const { updateShelf: updateHardcover } = useUpdateHardcoverShelf();
|
||||
const { squareCovers } = usePreferences();
|
||||
|
||||
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 {
|
||||
if (shelf.type === 'goodreads') {
|
||||
await deleteGoodreads(shelf.id);
|
||||
} else {
|
||||
await deleteHardcover(shelf.id);
|
||||
}
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutoRequest = async (shelf: GenericShelf) => {
|
||||
try {
|
||||
if (shelf.type === 'goodreads') {
|
||||
await updateGoodreads(shelf.id, { autoRequest: !shelf.autoRequest });
|
||||
} else {
|
||||
await updateHardcover(shelf.id, { autoRequest: !shelf.autoRequest });
|
||||
}
|
||||
} 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-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">
|
||||
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
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shelves.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => syncShelves()}
|
||||
disabled={isSyncingAll}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 rounded-xl hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-all duration-200 shadow-sm disabled:opacity-50"
|
||||
title="Resync all shelves"
|
||||
>
|
||||
<svg
|
||||
className={cn('w-4 h-4', isSyncingAll && 'animate-spin')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
{isSyncingAll ? 'Syncing...' : 'Resync All'}
|
||||
</button>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<ShelfCardSkeleton squareCovers={squareCovers} />
|
||||
) : shelves.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{shelves.map((shelf) => (
|
||||
<ShelfCard
|
||||
key={shelf.id}
|
||||
shelf={shelf}
|
||||
squareCovers={squareCovers}
|
||||
isDeleting={isDeleting && confirmDeleteId === shelf.id}
|
||||
isConfirmingDelete={confirmDeleteId === shelf.id}
|
||||
onDelete={() => handleDelete(shelf)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onManage={() => setManageShelf(shelf)}
|
||||
onToggleAutoRequest={() => handleToggleAutoRequest(shelf)}
|
||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState onAdd={() => setShowAddShelf(true)} />
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<AddShelfModal
|
||||
isOpen={showAddShelf}
|
||||
onClose={() => setShowAddShelf(false)}
|
||||
/>
|
||||
|
||||
<ManageShelfModal
|
||||
isOpen={!!manageShelf}
|
||||
onClose={() => setManageShelf(null)}
|
||||
shelf={manageShelf}
|
||||
/>
|
||||
|
||||
{selectedAsin && (
|
||||
<AudiobookDetailsModal
|
||||
asin={selectedAsin}
|
||||
isOpen={true}
|
||||
onClose={() => setSelectedAsin(null)}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Empty State ─── */
|
||||
|
||||
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-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>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-200 mb-1.5">
|
||||
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 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>
|
||||
Add Your First Shelf
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Loading Skeleton ─── */
|
||||
|
||||
function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7">
|
||||
<div className="mb-5">
|
||||
<div className="h-[18px] w-52 bg-gray-100 dark:bg-gray-700/50 rounded-lg animate-pulse mb-2.5" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[22px] w-16 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
|
||||
<div className="h-3.5 w-24 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
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]',
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Shelf Card ─── */
|
||||
|
||||
interface ShelfCardProps {
|
||||
shelf: GenericShelf;
|
||||
squareCovers: boolean;
|
||||
isDeleting: boolean;
|
||||
isConfirmingDelete: boolean;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onManage: () => void;
|
||||
onToggleAutoRequest: () => void;
|
||||
onBookClick: (asin: string) => void;
|
||||
}
|
||||
|
||||
function ShelfCard({
|
||||
shelf,
|
||||
squareCovers,
|
||||
isDeleting,
|
||||
isConfirmingDelete,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onManage,
|
||||
onToggleAutoRequest,
|
||||
onBookClick,
|
||||
}: ShelfCardProps) {
|
||||
const { syncShelves, isSyncing: isManualSyncing } = useSyncShelves();
|
||||
const displayBooks = shelf.books.slice(0, 6);
|
||||
const hasCovers = displayBooks.length > 0;
|
||||
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={cn(
|
||||
'group rounded-2xl bg-white dark:bg-gray-800 border p-6 sm:p-7 transition-all duration-300',
|
||||
shelf.autoRequest
|
||||
? 'border-gray-100 dark:border-gray-700/30 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40'
|
||||
: 'border-gray-200/60 dark:border-gray-700/20 bg-gray-50/50 dark:bg-gray-800/60',
|
||||
)}>
|
||||
{/* Top: Shelf info + actions */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between',
|
||||
(hasCovers || isSyncing) && 'mb-5',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className={cn(
|
||||
'font-semibold text-[15px] truncate leading-snug flex items-center',
|
||||
shelf.autoRequest
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-400 dark:text-gray-500',
|
||||
)}>
|
||||
{shelf.name} {providerIcon}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{shelf.bookCount != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
||||
</span>
|
||||
)}
|
||||
{!shelf.autoRequest && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-1 ring-amber-200/50 dark:ring-amber-500/20">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
Syncing…
|
||||
</>
|
||||
) : shelf.lastSyncAt ? (
|
||||
<>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
Synced {formatRelativeTime(shelf.lastSyncAt)}
|
||||
</>
|
||||
) : (
|
||||
'Pending sync'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete action */}
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
{isConfirmingDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-3 py-1.5 text-xs font-semibold text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Removing\u2026' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onToggleAutoRequest}
|
||||
className={cn(
|
||||
'p-2 transition-all duration-200 rounded-xl outline-none',
|
||||
shelf.autoRequest
|
||||
? 'text-gray-400 hover:text-amber-500 dark:text-gray-500 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-amber-500/40'
|
||||
: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 opacity-100',
|
||||
)}
|
||||
title={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
|
||||
aria-label={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
|
||||
>
|
||||
{shelf.autoRequest ? (
|
||||
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<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={() => syncShelves(shelf.id, shelf.type)}
|
||||
disabled={isManualSyncing}
|
||||
className="p-2 text-gray-400 hover:text-emerald-500 dark:text-gray-500 dark:hover:text-emerald-400 transition-all duration-200 rounded-xl hover:bg-emerald-50 dark:hover:bg-emerald-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-emerald-500/40 outline-none disabled:opacity-30"
|
||||
title="Resync shelf"
|
||||
aria-label="Resync shelf"
|
||||
>
|
||||
<svg
|
||||
className={cn(
|
||||
'w-[18px] h-[18px]',
|
||||
isManualSyncing && 'animate-spin',
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</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 */}
|
||||
<div className={cn(!shelf.autoRequest && 'opacity-50 grayscale-[30%]')}>
|
||||
{hasCovers ? (
|
||||
<CoverStack
|
||||
books={displayBooks}
|
||||
remainingCount={remainingCount}
|
||||
squareCovers={squareCovers}
|
||||
onBookClick={onBookClick}
|
||||
/>
|
||||
) : isSyncing ? (
|
||||
<div className="flex items-end">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
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]',
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Stacked Cover Display ─── */
|
||||
|
||||
function CoverStack({
|
||||
books,
|
||||
remainingCount,
|
||||
squareCovers,
|
||||
onBookClick,
|
||||
}: {
|
||||
books: ShelfBook[];
|
||||
remainingCount: number;
|
||||
squareCovers: boolean;
|
||||
onBookClick: (asin: string) => void;
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const coverSize = squareCovers
|
||||
? 'w-[80px] aspect-square'
|
||||
: 'w-[72px] aspect-[2/3]';
|
||||
|
||||
return (
|
||||
<div className="flex items-end">
|
||||
{books.map((book, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'relative rounded-xl overflow-hidden shadow-md flex-shrink-0',
|
||||
'ring-2 ring-white dark:ring-gray-800',
|
||||
'transition-all duration-300 ease-out',
|
||||
hoveredIndex === i && 'scale-[1.18] shadow-xl',
|
||||
coverSize,
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
style={{
|
||||
marginLeft: i > 0 ? '-16px' : 0,
|
||||
zIndex: hoveredIndex === i ? 50 : books.length - i,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(i)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => book.asin && onBookClick(book.asin)}
|
||||
title={
|
||||
book.asin
|
||||
? `${book.title}${book.author ? ` by ${book.author}` : ''}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
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>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<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,
|
||||
)}
|
||||
style={{ marginLeft: '-16px', zIndex: 0 }}
|
||||
>
|
||||
<span className="text-sm font-semibold text-gray-400 dark:text-gray-500 tabular-nums">
|
||||
+{remainingCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,24 +41,18 @@ 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';
|
||||
|
||||
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].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,259 @@
|
||||
/**
|
||||
* 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('');
|
||||
|
||||
// Shared State
|
||||
const [autoRequest, setAutoRequest] = useState(true);
|
||||
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, autoRequest);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
} else {
|
||||
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
|
||||
const shelf = await addHardcover(apiToken.trim(), finalId, autoRequest);
|
||||
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('');
|
||||
setAutoRequest(true);
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Auto-Request Toggle */}
|
||||
<label className="flex items-center justify-between gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/30 cursor-pointer select-none">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Auto-request books</span>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Automatically request audiobooks from this shelf
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoRequest}
|
||||
onClick={() => setAutoRequest(!autoRequest)}
|
||||
disabled={isLoading || success}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 ${
|
||||
autoRequest ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
} ${(isLoading || success) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
|
||||
autoRequest ? 'translate-x-4' : 'translate-x-0.5'
|
||||
} mt-0.5`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<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,154 @@
|
||||
/**
|
||||
* 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: rssUrl.trim() });
|
||||
} else {
|
||||
if (!listId.trim()) return;
|
||||
await updateHardcover(shelf.id, {
|
||||
listId: listId.trim(),
|
||||
apiToken: apiToken.trim() || undefined,
|
||||
forceSync: true,
|
||||
});
|
||||
}
|
||||
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,178 @@
|
||||
/**
|
||||
* 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: (latestData: { shelves: TShelf[] } | undefined) => {
|
||||
const shelves = latestData?.shelves || [];
|
||||
const hasSyncing = shelves.some(
|
||||
(s) => !(s as Record<string, unknown>)['lastSyncAt'],
|
||||
);
|
||||
return hasSyncing ? 3000 : 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 };
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface Audiobook {
|
||||
requestId?: string | null; // ID of request (if any)
|
||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
||||
}
|
||||
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||
|
||||
@@ -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;
|
||||
@@ -24,104 +16,33 @@ export interface GoodreadsShelf {
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
autoRequest: boolean;
|
||||
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);
|
||||
}
|
||||
const addShelf = async (rssUrl: string, autoRequest: boolean = true) => {
|
||||
return addGeneric({ rssUrl, autoRequest });
|
||||
};
|
||||
|
||||
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, updates: { rssUrl?: string; autoRequest?: boolean }) => {
|
||||
return updateGeneric(shelfId, updates);
|
||||
};
|
||||
|
||||
return { deleteShelf, isLoading, error };
|
||||
return { updateShelf, isLoading, error };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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;
|
||||
autoRequest: boolean;
|
||||
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, autoRequest: boolean = true) => {
|
||||
return addGeneric({ apiToken, listId, autoRequest });
|
||||
};
|
||||
|
||||
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; forceSync?: boolean; autoRequest?: boolean },
|
||||
) => {
|
||||
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,123 @@
|
||||
/**
|
||||
* Component: Ignored Audiobooks Hook
|
||||
* Documentation: documentation/features/ignored-audiobooks.md
|
||||
*
|
||||
* Provides hooks for checking and toggling audiobook ignore status.
|
||||
* - useIsIgnored(asin): check if a specific book is ignored
|
||||
* - useToggleIgnore(): toggle ignore on/off for a book
|
||||
* - useIgnoredList(): list all ignored books for the current user
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
interface IgnoredAudiobook {
|
||||
id: string;
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface IgnoreCheckResult {
|
||||
ignored: boolean;
|
||||
ignoredId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific ASIN is ignored by the current user.
|
||||
* Includes works-system expansion on the server side.
|
||||
*/
|
||||
export function useIsIgnored(asin: string | null) {
|
||||
const endpoint = asin ? `/api/user/ignored-audiobooks/check/${asin}` : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR<IgnoreCheckResult>(
|
||||
endpoint,
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isIgnored: data?.ignored ?? false,
|
||||
ignoredId: data?.ignoredId ?? null,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle ignore status for an audiobook.
|
||||
* Returns { addIgnore, removeIgnore } functions.
|
||||
*/
|
||||
export function useToggleIgnore() {
|
||||
const addIgnore = async (book: {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl?: string;
|
||||
}): Promise<IgnoredAudiobook> => {
|
||||
const res = await fetchWithAuth('/api/user/ignored-audiobooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(book),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || 'Failed to ignore audiobook');
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
// Invalidate the check cache for this ASIN
|
||||
mutate(`/api/user/ignored-audiobooks/check/${book.asin}`);
|
||||
// Invalidate the full list
|
||||
mutate('/api/user/ignored-audiobooks');
|
||||
|
||||
return result.ignoredAudiobook;
|
||||
};
|
||||
|
||||
const removeIgnore = async (id: string, asin: string): Promise<void> => {
|
||||
const res = await fetchWithAuth(`/api/user/ignored-audiobooks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || 'Failed to un-ignore audiobook');
|
||||
}
|
||||
|
||||
// Invalidate the check cache for this ASIN
|
||||
mutate(`/api/user/ignored-audiobooks/check/${asin}`);
|
||||
// Invalidate the full list
|
||||
mutate('/api/user/ignored-audiobooks');
|
||||
};
|
||||
|
||||
return { addIgnore, removeIgnore };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all ignored audiobooks for the current user.
|
||||
*/
|
||||
export function useIgnoredList() {
|
||||
const { data, error, isLoading } = useSWR<{ ignoredAudiobooks: IgnoredAudiobook[] }>(
|
||||
'/api/user/ignored-audiobooks',
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
ignoredAudiobooks: data?.ignoredAudiobooks ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
@@ -59,6 +60,95 @@ export function useRequests(status?: string, limit: number = 50, myOnly: boolean
|
||||
};
|
||||
}
|
||||
|
||||
// ── Active statuses that warrant live polling ────────────────────────────────
|
||||
const ACTIVE_STATUSES = new Set([
|
||||
'pending', 'awaiting_search', 'awaiting_approval',
|
||||
'searching', 'downloading', 'processing', 'awaiting_import',
|
||||
]);
|
||||
|
||||
export type RequestFilterGroup = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface RequestCounts {
|
||||
all: number;
|
||||
active: number;
|
||||
waiting: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
export interface RequestPage {
|
||||
requests: Request[];
|
||||
nextCursor: string | null;
|
||||
counts: RequestCounts;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Paginated hook for "My Requests" page.
|
||||
* Uses SWRInfinite for cursor-based pagination.
|
||||
* Polls only when active requests are present.
|
||||
*/
|
||||
export function useMyRequests(filter: RequestFilterGroup) {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const getKey = (pageIndex: number, previousPage: RequestPage | null): string | null => {
|
||||
if (!accessToken) return null;
|
||||
if (previousPage && !previousPage.nextCursor) return null; // reached end
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('myOnly', 'true');
|
||||
params.set('take', String(PAGE_SIZE));
|
||||
if (filter !== 'all') params.set('status', filter);
|
||||
if (pageIndex > 0 && previousPage?.nextCursor) {
|
||||
params.set('cursor', previousPage.nextCursor);
|
||||
}
|
||||
return `/api/requests?${params.toString()}`;
|
||||
};
|
||||
|
||||
const { data, error, isLoading, isValidating, size, setSize, mutate: revalidate } =
|
||||
useSWRInfinite<RequestPage>(getKey, fetcher, {
|
||||
revalidateFirstPage: true,
|
||||
revalidateOnFocus: false,
|
||||
// Smart polling: refresh every 5s only when active requests exist
|
||||
refreshInterval: (data) => {
|
||||
if (!data) return 5000;
|
||||
const hasActive = data.some(page =>
|
||||
page.requests.some(r => ACTIVE_STATUSES.has(r.status))
|
||||
);
|
||||
return hasActive ? 5000 : 0;
|
||||
},
|
||||
});
|
||||
|
||||
const allRequests = useMemo(
|
||||
() => data?.flatMap(page => page.requests) ?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
// Counts come from the first page (always the authoritative totals)
|
||||
const counts: RequestCounts = data?.[0]?.counts ?? {
|
||||
all: 0, active: 0, waiting: 0, completed: 0, failed: 0, cancelled: 0,
|
||||
};
|
||||
|
||||
const hasMore = data ? !!data[data.length - 1]?.nextCursor : false;
|
||||
const isLoadingMore = isValidating && size > 1 && !data?.[size - 1];
|
||||
const isEmpty = !isLoading && allRequests.length === 0;
|
||||
|
||||
const loadMore = () => setSize(s => s + 1);
|
||||
|
||||
return {
|
||||
requests: allRequests,
|
||||
counts,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
isEmpty,
|
||||
loadMore,
|
||||
revalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRequest(requestId: string) {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user