mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-13 09:40:11 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6025ac200a | |||
| 248bd5359c | |||
| 53c1e0dad7 | |||
| 45c8b614e3 | |||
| 24aa6afefc | |||
| 81813dc625 | |||
| f65cb59a9c | |||
| d1ea65a41a | |||
| a5e7af1a53 | |||
| ca02b8b6e7 | |||
| 85aa80938a | |||
| efb4f64014 | |||
| 95917715b1 | |||
| a50fbc721e | |||
| d6eca611fc | |||
| 45e818c181 | |||
| 85977d123c | |||
| 441724c378 | |||
| d0ce485bdc | |||
| c29cfa3a07 | |||
| 7f706e806f | |||
| 338331d006 | |||
| 6ca2e964e8 | |||
| 1d1aaa7ff3 | |||
| cbf02d3e24 | |||
| f0b2476b87 | |||
| 04b6a2c135 | |||
| 6da2c4ce95 | |||
| ce8f4d642b | |||
| 61b183542c | |||
| ae4a73144d | |||
| c57d0c1492 | |||
| 8f8387abff | |||
| 4ae68d01de | |||
| 225ef8c919 | |||
| 610873af6b | |||
| ff80d995c5 | |||
| 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
|
||||
|
||||
@@ -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)
|
||||
@@ -150,3 +158,7 @@
|
||||
**"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 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)
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "works" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "works_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "work_asins" (
|
||||
"id" TEXT NOT NULL,
|
||||
"work_id" TEXT NOT NULL,
|
||||
"asin" TEXT NOT NULL,
|
||||
"narrator" TEXT,
|
||||
"duration_minutes" INTEGER,
|
||||
"is_canonical" BOOLEAN NOT NULL DEFAULT false,
|
||||
"source" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "work_asins_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "works_title_idx" ON "works"("title");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "works_author_idx" ON "works"("author");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "work_asins_asin_key" ON "work_asins"("asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "work_asins_work_id_idx" ON "work_asins"("work_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "work_asins_asin_idx" ON "work_asins"("asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "work_asins" ADD CONSTRAINT "work_asins_work_id_fkey" FOREIGN KEY ("work_id") REFERENCES "works"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "watched_series" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"series_asin" TEXT NOT NULL,
|
||||
"series_title" TEXT NOT NULL,
|
||||
"cover_art_url" TEXT,
|
||||
"last_checked_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "watched_series_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "watched_authors" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"author_asin" TEXT NOT NULL,
|
||||
"author_name" TEXT NOT NULL,
|
||||
"cover_art_url" TEXT,
|
||||
"last_checked_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "watched_authors_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_series_user_id_idx" ON "watched_series"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_series_series_asin_idx" ON "watched_series"("series_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "watched_series_user_id_series_asin_key" ON "watched_series"("user_id", "series_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_authors_user_id_idx" ON "watched_authors"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_authors_author_asin_idx" ON "watched_authors"("author_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "watched_authors_user_id_author_asin_key" ON "watched_authors"("user_id", "author_asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "watched_series" ADD CONSTRAINT "watched_series_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "watched_authors" ADD CONSTRAINT "watched_authors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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,3 @@
|
||||
-- Normalize existing local usernames to lowercase
|
||||
UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL;
|
||||
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%';
|
||||
@@ -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,33 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "api_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"token_prefix" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'user',
|
||||
"created_by_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"last_used_at" TIMESTAMP(3),
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_created_by_id_idx" ON "api_tokens"("created_by_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+163
-13
@@ -66,8 +66,13 @@ 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[]
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -496,6 +501,34 @@ model ReportedIssue {
|
||||
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// API TOKEN TABLE
|
||||
// Static API tokens for programmatic access (alternative to JWT)
|
||||
// Documentation: documentation/backend/services/api-tokens.md
|
||||
// ============================================================================
|
||||
|
||||
model ApiToken {
|
||||
id String @id @default(uuid())
|
||||
name String // User-friendly label (e.g., "Home Assistant", "Webhook")
|
||||
tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
|
||||
tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
|
||||
role String @default("user") // Token role: 'admin' or 'user'
|
||||
createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
|
||||
userId String @map("user_id") // The user identity this token acts as
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
expiresAt DateTime? @map("expires_at") // null = never expires
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tokenHash])
|
||||
@@index([createdById])
|
||||
@@index([userId])
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
model GoodreadsShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
@@ -515,19 +548,136 @@ model GoodreadsShelf {
|
||||
@@map("goodreads_shelves")
|
||||
}
|
||||
|
||||
model GoodreadsBookMapping {
|
||||
id String @id @default(uuid())
|
||||
goodreadsBookId String @unique @map("goodreads_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
// ============================================================================
|
||||
// UNIFIED BOOK MAPPING TABLE
|
||||
// Global book-to-ASIN mapping cache shared across all shelf providers.
|
||||
// Uses provider + externalBookId composite key for cross-provider dedup.
|
||||
// ============================================================================
|
||||
|
||||
@@index([goodreadsBookId])
|
||||
model BookMapping {
|
||||
id String @id @default(uuid())
|
||||
provider String // "goodreads", "hardcover", etc.
|
||||
externalBookId String @map("external_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([provider, externalBookId])
|
||||
@@index([provider, externalBookId])
|
||||
@@index([audibleAsin])
|
||||
@@map("goodreads_book_mappings")
|
||||
@@map("book_mappings")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HARDCOVER SYNC TABLES
|
||||
// Per-user Hardcover list subscriptions
|
||||
// ============================================================================
|
||||
|
||||
model HardcoverShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
name String // Extracted from Hardcover API list name or status
|
||||
listId String @map("list_id") // Hardcover List ID or Status ID
|
||||
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
bookCount Int? @map("book_count")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, listId])
|
||||
@@index([userId])
|
||||
@@map("hardcover_shelves")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKS TABLE
|
||||
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
|
||||
// to a single logical work for library matching across editions.
|
||||
// Documentation: documentation/integrations/audible.md
|
||||
// ============================================================================
|
||||
|
||||
model Work {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
author String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
asins WorkAsin[]
|
||||
|
||||
@@index([title])
|
||||
@@index([author])
|
||||
@@map("works")
|
||||
}
|
||||
|
||||
model WorkAsin {
|
||||
id String @id @default(uuid())
|
||||
workId String @map("work_id")
|
||||
asin String @unique
|
||||
narrator String?
|
||||
durationMinutes Int? @map("duration_minutes")
|
||||
isCanonical Boolean @default(false) @map("is_canonical")
|
||||
source String // 'dedup_auto' | 'admin_manual'
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workId])
|
||||
@@index([asin])
|
||||
@@map("work_asins")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WATCHED LISTS TABLES
|
||||
// Per-user series and author subscriptions for automatic new-release requests.
|
||||
// Documentation: documentation/features/watched-lists.md
|
||||
// ============================================================================
|
||||
|
||||
model WatchedSeries {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
seriesAsin String @map("series_asin")
|
||||
seriesTitle String @map("series_title")
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
lastCheckedAt DateTime? @map("last_checked_at")
|
||||
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, seriesAsin])
|
||||
@@index([userId])
|
||||
@@index([seriesAsin])
|
||||
@@map("watched_series")
|
||||
}
|
||||
|
||||
model WatchedAuthor {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
authorAsin String @map("author_asin")
|
||||
authorName String @map("author_name")
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
lastCheckedAt DateTime? @map("last_checked_at")
|
||||
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, authorAsin])
|
||||
@@index([userId])
|
||||
@@index([authorAsin])
|
||||
@@map("watched_authors")
|
||||
}
|
||||
|
||||
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 |
@@ -2,12 +2,13 @@
|
||||
* Component: Confirm Dialog
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Reusable confirmation dialog for destructive actions
|
||||
* Reusable confirmation dialog for destructive actions.
|
||||
* Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -30,99 +31,177 @@ export function ConfirmDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus the cancel button on open (safer default for destructive dialogs)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Small delay to let animation start before stealing focus
|
||||
const t = setTimeout(() => cancelRef.current?.focus(), 50);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Escape to close + focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus trap: tab cycles only within dialog
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (!focusable || focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
// Prevent body scroll while open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const confirmButtonClasses =
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white';
|
||||
const isDestructive = confirmVariant === 'danger';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-desc"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
className="animate-dialog-backdrop fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-100 dark:bg-red-900'
|
||||
: 'bg-blue-100 dark:bg-blue-900'
|
||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
||||
>
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="animate-dialog-panel relative w-full max-w-sm rounded-2xl overflow-hidden bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/10 dark:ring-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon well */}
|
||||
<div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
isDestructive
|
||||
? 'bg-red-50 dark:bg-red-500/10'
|
||||
: 'bg-blue-50 dark:bg-blue-500/10'
|
||||
}`}>
|
||||
{isDestructive ? (
|
||||
<svg
|
||||
className={`h-6 w-6 ${
|
||||
confirmVariant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
className="w-5 h-5 text-red-500 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.75"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{confirmVariant === 'danger' ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-500 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.75"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h3
|
||||
id="confirm-dialog-title"
|
||||
className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div id="confirm-dialog-desc" className="mt-1.5">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 bg-gray-50/80 dark:bg-white/[0.03] border-t border-gray-100 dark:border-white/[0.06]">
|
||||
<button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium rounded-xl text-gray-700 dark:text-gray-300 bg-white dark:bg-white/[0.06] hover:bg-gray-100 dark:hover:bg-white/[0.1] border border-gray-200 dark:border-white/[0.1] transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-xl text-white transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 active:scale-[0.97] ${
|
||||
isDestructive
|
||||
? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,10 +62,9 @@ export function RequestActionsDropdown({
|
||||
// View Details: available when ASIN exists (audiobook requests only)
|
||||
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
||||
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
// Determine available actions based on status
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
@@ -130,7 +129,11 @@ export function RequestActionsDropdown({
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearch(true);
|
||||
if (isEbook) {
|
||||
setShowInteractiveSearchEbook(true);
|
||||
} else {
|
||||
setShowInteractiveSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdjustSearchTerms = () => {
|
||||
@@ -513,6 +516,7 @@ export function RequestActionsDropdown({
|
||||
author: request.author,
|
||||
}}
|
||||
searchMode="ebook"
|
||||
customSearchTerms={request.customSearchTerms}
|
||||
/>
|
||||
|
||||
{/* Adjust Search Terms Modal */}
|
||||
|
||||
@@ -210,6 +210,7 @@ export const getTabValidation = (
|
||||
return validated.paths;
|
||||
case 'ebook':
|
||||
case 'bookdate':
|
||||
case 'api':
|
||||
return true; // These tabs handle their own saving
|
||||
default:
|
||||
return false;
|
||||
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
||||
{ id: 'api' as const, label: 'API', icon: '🔑' },
|
||||
];
|
||||
|
||||
@@ -243,4 +243,4 @@ export interface BookDateModel {
|
||||
/**
|
||||
* Tab identifier type
|
||||
*/
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api';
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
|
||||
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
||||
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
||||
import { NotificationsTab } from './tabs/NotificationsTab';
|
||||
import { ApiTab } from './tabs/ApiTab/ApiTab';
|
||||
|
||||
// Types and Helpers
|
||||
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
||||
@@ -346,8 +347,11 @@ export default function AdminSettings() {
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && <ApiTab />}
|
||||
|
||||
{/* Save Button (only for tabs that save through main page) */}
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Component: API Token Management Tab (Admin)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
|
||||
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
import type { AdminApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function ApiTab() {
|
||||
const api = useApiTokens<AdminApiToken>({ basePath: '/api/admin/api-tokens' });
|
||||
|
||||
// Admin-specific state
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [newTokenUserId, setNewTokenUserId] = useState('');
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/users');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, user selector just won't populate
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const extraBody: Record<string, string> = {};
|
||||
if (newTokenUserId) extraBody.userId = newTokenUserId;
|
||||
const created = await api.handleCreate(extraBody);
|
||||
// Reset admin-specific fields only when create succeeds
|
||||
if (created) {
|
||||
setNewTokenUserId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
api.resetForm();
|
||||
setNewTokenUserId('');
|
||||
};
|
||||
|
||||
if (api.loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage API tokens for all users. Create tokens for any user for programmatic access.{' '}
|
||||
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View API documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{api.error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{api.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{api.createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{api.createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={api.handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{api.copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss token banner"
|
||||
onClick={api.dismissCreatedToken}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{api.showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<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-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={api.newTokenName}
|
||||
onChange={(e) => api.setNewTokenName(e.target.value)}
|
||||
placeholder="e.g., Home Assistant, Webhook"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={api.newTokenExpiry}
|
||||
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
User (acts as)
|
||||
</label>
|
||||
<select
|
||||
value={newTokenUserId}
|
||||
onChange={(e) => setNewTokenUserId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Current user (default)</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.plexUsername} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Token will inherit the selected user's role
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={api.creating || !api.newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{api.creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => api.setShowCreateForm(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
Create New Token
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{api.tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Acts As</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Role</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Created By</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{api.tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.tokenUser}</td>
|
||||
<td className="py-3 px-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
token.role === 'admin'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{token.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{api.formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||
disabled={api.deletingId === token.id}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Revoke confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={api.confirmRevokeId !== null}
|
||||
title="Revoke API token"
|
||||
message={
|
||||
<>
|
||||
Are you sure you want to revoke{' '}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</span>
|
||||
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Revoke token"
|
||||
cancelLabel="Cancel"
|
||||
confirmVariant="danger"
|
||||
onConfirm={api.handleDeleteConfirmed}
|
||||
onCancel={() => api.setConfirmRevokeId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Component: Interactive API Documentation Page
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Lists all API token-accessible endpoints with "Try it out" functionality.
|
||||
* Users can test with a custom API token or their current browser session.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { TokenInput } from '@/components/api-docs/TokenInput';
|
||||
import { EndpointCard } from '@/components/api-docs/EndpointCard';
|
||||
import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { user } = useAuth();
|
||||
const [token, setToken] = useState('');
|
||||
const [useSession, setUseSession] = useState(false);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 pt-8 pb-16">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-gray-900 dark:text-white font-medium">API Documentation</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
API Reference
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-500 dark:text-gray-400 leading-relaxed max-w-2xl">
|
||||
Interact with ReadMeABook programmatically using API tokens. These endpoints are
|
||||
available for external integrations, dashboards, and automation tools.
|
||||
</p>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="flex flex-wrap gap-3 mt-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Manage your tokens
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Admin token management
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication section */}
|
||||
<div className="mb-8">
|
||||
<TokenInput
|
||||
token={token}
|
||||
onTokenChange={setToken}
|
||||
useSession={useSession}
|
||||
onUseSessionChange={setUseSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage instructions card */}
|
||||
<div className="mb-8 rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Quick Start
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Include your API token in the <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-900 rounded text-xs font-mono">Authorization</code> header as a Bearer token:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-xl overflow-x-auto font-mono leading-relaxed">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Endpoints section header */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Available Endpoints
|
||||
</h2>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||
{API_TOKEN_ENDPOINT_DOCS.length} endpoints
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Endpoint cards */}
|
||||
<div className="space-y-4">
|
||||
{API_TOKEN_ENDPOINT_DOCS.map((endpoint) => (
|
||||
<EndpointCard
|
||||
key={`${endpoint.method}:${endpoint.path}`}
|
||||
endpoint={endpoint}
|
||||
token={token}
|
||||
useSession={useSession}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="mt-10 text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
API tokens are restricted to the endpoints listed above.
|
||||
JWT session authentication has access to all endpoints.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Component: API Token Delete Route
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/api-tokens/[id]
|
||||
* Revoke (delete) an API token
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiToken.delete({ where: { id } });
|
||||
|
||||
logger.info('API token revoked', { tokenId: id, name: token.name, revokedBy: req.user!.username });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Component: Admin API Token Management Routes
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
const CreateTokenSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
userId: z.string().uuid().optional(), // Admin can specify which user the token acts as
|
||||
role: z.enum(['admin', 'user']).optional(), // Accepted for compatibility, but cannot differ from target user role
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/api-tokens
|
||||
* List ALL API tokens across all users
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
include: {
|
||||
createdBy: {
|
||||
select: { id: true, plexUsername: true },
|
||||
},
|
||||
tokenUser: {
|
||||
select: { id: true, plexUsername: true, role: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const sanitized = tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
role: t.role,
|
||||
createdBy: t.createdBy.plexUsername,
|
||||
createdById: t.createdBy.id,
|
||||
tokenUser: t.tokenUser.plexUsername,
|
||||
tokenUserId: t.tokenUser.id,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
expiresAt: t.expiresAt,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tokens: sanitized });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/api-tokens
|
||||
* Create a new API token. Admin can optionally specify userId.
|
||||
* Token role is always derived from the target user's current role.
|
||||
* Returns the full token ONCE.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body);
|
||||
|
||||
// Determine target user (defaults to the admin themselves)
|
||||
const targetUserId = userId || req.user!.id;
|
||||
|
||||
// Verify the target user exists
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, role: true, plexUsername: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'Target user not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||
const activeTokenCount = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: targetUserId,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Security guard: token role must always match the target user's persisted role.
|
||||
// This avoids role/identity mismatch (for example: acting as user A with admin role).
|
||||
if (role && role !== targetUser.role) {
|
||||
logger.warn('Admin attempted token role override that differs from target user role', {
|
||||
requestedRole: role,
|
||||
userActualRole: targetUser.role,
|
||||
targetUser: targetUser.plexUsername,
|
||||
createdBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Token role must match target user's role (${targetUser.role}).`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tokenRole = targetUser.role;
|
||||
|
||||
// Generate the token
|
||||
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
role: tokenRole,
|
||||
createdById: req.user!.id,
|
||||
userId: targetUserId,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Admin API token created', {
|
||||
tokenId: apiToken.id,
|
||||
name,
|
||||
createdBy: req.user!.username,
|
||||
targetUser: targetUser.plexUsername,
|
||||
role: tokenRole,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
token: {
|
||||
id: apiToken.id,
|
||||
name: apiToken.name,
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
role: apiToken.role,
|
||||
expiresAt: apiToken.expiresAt,
|
||||
createdAt: apiToken.createdAt,
|
||||
},
|
||||
// Full token is returned ONLY on creation
|
||||
fullToken,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ManualImport');
|
||||
|
||||
@@ -174,6 +175,48 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts)
|
||||
if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) {
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
|
||||
|
||||
if (audnexusData) {
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (!audiobook.series && audnexusData.series) {
|
||||
updates.series = audnexusData.series;
|
||||
}
|
||||
if (!audiobook.seriesPart && audnexusData.seriesPart) {
|
||||
updates.seriesPart = audnexusData.seriesPart;
|
||||
}
|
||||
if (!audiobook.seriesAsin && audnexusData.seriesAsin) {
|
||||
updates.seriesAsin = audnexusData.seriesAsin;
|
||||
}
|
||||
if (!audiobook.year && audnexusData.releaseDate) {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
updates.year = releaseYear;
|
||||
}
|
||||
}
|
||||
if (!audiobook.narrator && audnexusData.narrator) {
|
||||
updates.narrator = audnexusData.narrator;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal: series enrichment failure should never block the import
|
||||
logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing requests
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
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';
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isNewRelease: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
|
||||
// Query audible_cache for new release audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isNewRelease: true,
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
newReleaseRank: 'asc',
|
||||
},
|
||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: {
|
||||
isNewRelease: true,
|
||||
},
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
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';
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isPopular: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
|
||||
// Query audible_cache for popular audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isPopular: true,
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: {
|
||||
isPopular: true,
|
||||
},
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
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';
|
||||
|
||||
@@ -38,14 +40,22 @@ export async function GET(request: NextRequest) {
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// Enrich search results with availability and request status information
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId);
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
query: results.query,
|
||||
results: enrichedResults,
|
||||
totalResults: results.totalResults,
|
||||
totalResults: enrichedResults.length,
|
||||
page: results.page,
|
||||
hasMore: results.hasMore,
|
||||
});
|
||||
|
||||
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
|
||||
// Find user by local admin identifier
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { plexId: `local-${username}` },
|
||||
where: { plexId: `local-${normalizedUsername}` },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
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';
|
||||
|
||||
@@ -53,9 +55,17 @@ export async function GET(
|
||||
const audibleService = getAudibleService();
|
||||
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// Enrich with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(result.books, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
|
||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
||||
|
||||
@@ -64,7 +74,7 @@ export async function GET(
|
||||
books: enrichedBooks,
|
||||
authorName: authorName.trim(),
|
||||
authorAsin: asin,
|
||||
totalBooks: result.totalResults || enrichedBooks.length,
|
||||
totalBooks: enrichedBooks.length,
|
||||
hasMore: result.hasMore,
|
||||
page: result.page,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
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';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Detail');
|
||||
|
||||
@@ -49,9 +51,17 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// Enrich books with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
|
||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
||||
|
||||
|
||||
@@ -140,14 +140,15 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedAdminUsername = admin.username.trim().toLowerCase();
|
||||
const hashedPassword = await bcrypt.hash(admin.password, 10);
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
||||
|
||||
adminUser = await prisma.user.create({
|
||||
data: {
|
||||
plexId: `local-${admin.username}`,
|
||||
plexUsername: admin.username,
|
||||
plexId: `local-${normalizedAdminUsername}`,
|
||||
plexUsername: normalizedAdminUsername,
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Component: User API Token Delete Route (self-service)
|
||||
* Documentation: documentation/backend/services/api-tokens.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 { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/api-tokens/[id]
|
||||
* Revoke (delete) one of the current user's own API tokens
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow deleting own tokens
|
||||
if (token.userId !== req.user!.id) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiToken.delete({ where: { id } });
|
||||
|
||||
logger.info('User API token revoked', { tokenId: id, name: token.name, userId: req.user!.id });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke user API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Component: User API Token Routes (self-service)
|
||||
* Documentation: documentation/backend/services/api-tokens.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 { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||
|
||||
const CreateTokenSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/api-tokens
|
||||
* List the current user's own API tokens
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const sanitized = tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
role: t.role,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
expiresAt: t.expiresAt,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tokens: sanitized });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list user API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/api-tokens
|
||||
* Create a token for the current user with their own role. Returns full token ONCE.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, expiresAt } = CreateTokenSchema.parse(body);
|
||||
|
||||
// Look up the user's actual role from the database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||
const activeTokenCount = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: req.user!.id,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate the token
|
||||
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
role: user.role, // Always the user's own role
|
||||
createdById: req.user!.id,
|
||||
userId: req.user!.id, // Token acts as the current user
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('User API token created', { tokenId: apiToken.id, name, userId: req.user!.id });
|
||||
|
||||
return NextResponse.json({
|
||||
token: {
|
||||
id: apiToken.id,
|
||||
name: apiToken.name,
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
role: apiToken.role,
|
||||
expiresAt: apiToken.expiresAt,
|
||||
createdAt: apiToken.createdAt,
|
||||
},
|
||||
fullToken,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create user API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,9 +7,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||
|
||||
const UpdateGoodreadsSchema = z.object({
|
||||
rssUrl: z.string().url('Must be a valid URL'),
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/user/goodreads-shelves/[id]
|
||||
* Remove a Goodreads shelf subscription (ownership check)
|
||||
@@ -48,3 +54,57 @@ export async function DELETE(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/user/goodreads-shelves/[id]
|
||||
* Update a Goodreads shelf subscription
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const shelf = await prisma.goodreadsShelf.findUnique({ where: { id } });
|
||||
|
||||
if (!shelf) {
|
||||
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (shelf.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { rssUrl } = UpdateGoodreadsSchema.parse(body);
|
||||
|
||||
// Force re-fetch by clearing metadata
|
||||
const updated = await prisma.goodreadsShelf.update({
|
||||
where: { id },
|
||||
data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null },
|
||||
});
|
||||
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, shelf: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
|
||||
}
|
||||
logger.error('Failed to update shelf', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to update shelf' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ export async function POST(request: NextRequest) {
|
||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
|
||||
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
|
||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
|
||||
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Component: Hardcover Shelf Delete Route
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.HardcoverShelves');
|
||||
|
||||
const UpdateHardcoverSchema = z.object({
|
||||
listId: z.string().min(1, 'List ID is required').optional(),
|
||||
apiToken: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/user/hardcover-shelves/[id]
|
||||
* Remove a Hardcover shelf subscription (ownership check)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const shelf = await prisma.hardcoverShelf.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!shelf) {
|
||||
return NextResponse.json({ error: 'List not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (shelf.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.hardcoverShelf.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete list', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete list' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/user/hardcover-shelves/[id]
|
||||
* Update a Hardcover shelf subscription
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const shelf = await prisma.hardcoverShelf.findUnique({ where: { id } });
|
||||
|
||||
if (!shelf) {
|
||||
return NextResponse.json({ error: 'List not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (shelf.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
|
||||
|
||||
const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
||||
let needsResync = false;
|
||||
|
||||
let cleanedToken: string | undefined;
|
||||
if (apiToken && apiToken.trim() !== '') {
|
||||
cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ')
|
||||
? apiToken.trim().slice(7).trim()
|
||||
: apiToken.trim();
|
||||
}
|
||||
|
||||
const newListId = (listId && listId !== shelf.listId) ? listId : undefined;
|
||||
|
||||
// Validate token/listId by fetching the list before saving
|
||||
if (cleanedToken || newListId) {
|
||||
const encryptionService = getEncryptionService();
|
||||
let tokenToTest = cleanedToken || shelf.apiToken;
|
||||
if (!cleanedToken) {
|
||||
try {
|
||||
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
|
||||
tokenToTest = encryptionService.decrypt(shelf.apiToken);
|
||||
}
|
||||
} catch {
|
||||
// Decryption failed, fall back to raw token
|
||||
}
|
||||
}
|
||||
const listIdToTest = newListId || shelf.listId;
|
||||
|
||||
try {
|
||||
await fetchHardcoverList(tokenToTest, listIdToTest);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidHardcoverList',
|
||||
message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (newListId) {
|
||||
updateData.listId = newListId;
|
||||
needsResync = true;
|
||||
}
|
||||
if (cleanedToken) {
|
||||
updateData.apiToken = encryptionService.encrypt(cleanedToken);
|
||||
needsResync = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we are forcing a resync due to a change, clear metadata
|
||||
if (needsResync) {
|
||||
updateData.lastSyncAt = null;
|
||||
updateData.bookCount = null;
|
||||
updateData.coverUrls = null;
|
||||
}
|
||||
|
||||
const updated = await prisma.hardcoverShelf.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
if (needsResync) {
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, shelf: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
|
||||
}
|
||||
logger.error('Failed to update list', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to update list' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Component: Hardcover Shelves API Routes
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { processBooks } from '@/lib/utils/shelf-helpers';
|
||||
|
||||
const logger = RMABLogger.create('API.HardcoverShelves');
|
||||
|
||||
const AddShelfSchema = z.object({
|
||||
listId: z.string().min(1, { message: 'List ID is required' }),
|
||||
apiToken: z.string().min(1, { message: 'API Token is required' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/hardcover-shelves
|
||||
* List the current user's Hardcover lists with book counts and covers
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const shelves = await prisma.hardcoverShelf.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const shelvesWithMeta = shelves.map((shelf) => {
|
||||
const books = processBooks(shelf.coverUrls);
|
||||
|
||||
return {
|
||||
id: shelf.id,
|
||||
name: shelf.name,
|
||||
listId: shelf.listId,
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount ?? null,
|
||||
books,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list Hardcover lists', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list Hardcover lists' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/hardcover-shelves
|
||||
* Add a new Hardcover list subscription
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
let { listId, apiToken } = AddShelfSchema.parse(body);
|
||||
|
||||
// Clean up token in case user pasted "Bearer " prefix
|
||||
apiToken = apiToken.trim();
|
||||
if (apiToken.toLowerCase().startsWith('bearer ')) {
|
||||
apiToken = apiToken.slice(7).trim();
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.hardcoverShelf.findUnique({
|
||||
where: { userId_listId: { userId: req.user.id, listId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'DuplicateShelf',
|
||||
message: 'You have already added this list',
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate by fetching the Hardcover GraphQL feed
|
||||
let listName: string;
|
||||
let bookCount: number;
|
||||
let initialBooks: {
|
||||
coverUrl: string;
|
||||
asin: null;
|
||||
title: string;
|
||||
author: string;
|
||||
}[] = [];
|
||||
try {
|
||||
const fetchedData = await fetchHardcoverList(apiToken, listId);
|
||||
listName = fetchedData.listName;
|
||||
bookCount = fetchedData.books.length;
|
||||
initialBooks = fetchedData.books
|
||||
.filter((b) => b.coverUrl)
|
||||
.slice(0, 8)
|
||||
.map((b) => ({
|
||||
coverUrl: b.coverUrl!,
|
||||
asin: null,
|
||||
title: b.title,
|
||||
author: b.author,
|
||||
}));
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidHardcoverList',
|
||||
message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedToken = encryptionService.encrypt(apiToken);
|
||||
|
||||
const shelf = await prisma.hardcoverShelf.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
name: listName,
|
||||
listId,
|
||||
apiToken: encryptedToken,
|
||||
bookCount,
|
||||
coverUrls:
|
||||
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
|
||||
logger.info(
|
||||
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
shelf: {
|
||||
id: shelf.id,
|
||||
name: shelf.name,
|
||||
listId: shelf.listId,
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount,
|
||||
books: initialBooks,
|
||||
},
|
||||
bookCount,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to add Hardcover list', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to add Hardcover list' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Component: Combined Shelves API Routes
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { processBooks } from '@/lib/utils/shelf-helpers';
|
||||
|
||||
const logger = RMABLogger.create('API.Shelves');
|
||||
|
||||
/**
|
||||
* GET /api/user/shelves
|
||||
* List the current user's shelves (Goodreads, Hardcover) with book counts and covers
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const [goodreads, hardcover] = await Promise.all([
|
||||
prisma.goodreadsShelf.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.hardcoverShelf.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const combined = [
|
||||
...goodreads.map((s) => ({
|
||||
id: s.id,
|
||||
type: 'goodreads',
|
||||
name: s.name,
|
||||
sourceId: s.rssUrl,
|
||||
lastSyncAt: s.lastSyncAt,
|
||||
createdAt: s.createdAt,
|
||||
bookCount: s.bookCount ?? null,
|
||||
books: processBooks(s.coverUrls),
|
||||
})),
|
||||
...hardcover.map((s) => ({
|
||||
id: s.id,
|
||||
type: 'hardcover',
|
||||
name: s.name,
|
||||
sourceId: s.listId,
|
||||
lastSyncAt: s.lastSyncAt,
|
||||
createdAt: s.createdAt,
|
||||
bookCount: s.bookCount ?? null,
|
||||
books: processBooks(s.coverUrls),
|
||||
})),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, shelves: combined });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list shelves', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list shelves' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Component: Watched Author Delete Route
|
||||
* Documentation: documentation/features/watched-lists.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';
|
||||
|
||||
const logger = RMABLogger.create('API.WatchedAuthors');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/watched-authors/[id]
|
||||
* Remove an author from the user's watch list (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 watched = await prisma.watchedAuthor.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!watched) {
|
||||
return NextResponse.json({ error: 'Watched author not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (watched.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.watchedAuthor.delete({ where: { id } });
|
||||
|
||||
logger.info(`User ${req.user.id} stopped watching author "${watched.authorName}" (${watched.authorAsin})`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete watched author', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to delete watched author' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: Watched Authors API Routes
|
||||
* Documentation: documentation/features/watched-lists.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 { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.WatchedAuthors');
|
||||
|
||||
const AddWatchedAuthorSchema = z.object({
|
||||
authorAsin: z.string().regex(/^[A-Z0-9]{10}$/, 'Invalid author ASIN'),
|
||||
authorName: z.string().min(1).max(500),
|
||||
coverArtUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/watched-authors
|
||||
* List the current user's watched authors
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const authors = await prisma.watchedAuthor.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
authors: authors.map((a) => ({
|
||||
id: a.id,
|
||||
authorAsin: a.authorAsin,
|
||||
authorName: a.authorName,
|
||||
coverArtUrl: a.coverArtUrl,
|
||||
lastCheckedAt: a.lastCheckedAt,
|
||||
createdAt: a.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list watched authors', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list watched authors' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/watched-authors
|
||||
* Add an author to the user's watch 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 { authorAsin, authorName, coverArtUrl } = AddWatchedAuthorSchema.parse(body);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.watchedAuthor.findUnique({
|
||||
where: { userId_authorAsin: { userId: req.user.id, authorAsin } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AlreadyWatching', message: 'You are already watching this author' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const watched = await prisma.watchedAuthor.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
authorAsin,
|
||||
authorName,
|
||||
coverArtUrl: coverArtUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`User ${req.user.id} started watching author "${authorName}" (${authorAsin})`);
|
||||
|
||||
// Trigger immediate targeted check for this author (fire-and-forget)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addCheckWatchedItemJob(req.user.id, undefined, authorAsin);
|
||||
logger.info(`Triggered immediate check for watched author "${authorName}" (${authorAsin})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate watched author check', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
author: {
|
||||
id: watched.id,
|
||||
authorAsin: watched.authorAsin,
|
||||
authorName: watched.authorName,
|
||||
coverArtUrl: watched.coverArtUrl,
|
||||
lastCheckedAt: watched.lastCheckedAt,
|
||||
createdAt: watched.createdAt,
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to add watched author', { 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 watched author' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Component: Watched Series Delete Route
|
||||
* Documentation: documentation/features/watched-lists.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';
|
||||
|
||||
const logger = RMABLogger.create('API.WatchedSeries');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/watched-series/[id]
|
||||
* Remove a series from the user's watch list (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 watched = await prisma.watchedSeries.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!watched) {
|
||||
return NextResponse.json({ error: 'Watched series not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (watched.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.watchedSeries.delete({ where: { id } });
|
||||
|
||||
logger.info(`User ${req.user.id} stopped watching series "${watched.seriesTitle}" (${watched.seriesAsin})`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete watched series', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to delete watched series' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: Watched Series API Routes
|
||||
* Documentation: documentation/features/watched-lists.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 { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.WatchedSeries');
|
||||
|
||||
const AddWatchedSeriesSchema = z.object({
|
||||
seriesAsin: z.string().regex(/^[A-Z0-9]{10}$/, 'Invalid series ASIN'),
|
||||
seriesTitle: z.string().min(1).max(500),
|
||||
coverArtUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/watched-series
|
||||
* List the current user's watched series
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const series = await prisma.watchedSeries.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series: series.map((s) => ({
|
||||
id: s.id,
|
||||
seriesAsin: s.seriesAsin,
|
||||
seriesTitle: s.seriesTitle,
|
||||
coverArtUrl: s.coverArtUrl,
|
||||
lastCheckedAt: s.lastCheckedAt,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list watched series', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list watched series' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/watched-series
|
||||
* Add a series to the user's watch 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 { seriesAsin, seriesTitle, coverArtUrl } = AddWatchedSeriesSchema.parse(body);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.watchedSeries.findUnique({
|
||||
where: { userId_seriesAsin: { userId: req.user.id, seriesAsin } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AlreadyWatching', message: 'You are already watching this series' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const watched = await prisma.watchedSeries.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
seriesAsin,
|
||||
seriesTitle,
|
||||
coverArtUrl: coverArtUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`User ${req.user.id} started watching series "${seriesTitle}" (${seriesAsin})`);
|
||||
|
||||
// Trigger immediate targeted check for this series (fire-and-forget)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addCheckWatchedItemJob(req.user.id, seriesAsin);
|
||||
logger.info(`Triggered immediate check for watched series "${seriesTitle}" (${seriesAsin})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate watched series check', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series: {
|
||||
id: watched.id,
|
||||
seriesAsin: watched.seriesAsin,
|
||||
seriesTitle: watched.seriesTitle,
|
||||
coverArtUrl: watched.coverArtUrl,
|
||||
lastCheckedAt: watched.lastCheckedAt,
|
||||
createdAt: watched.createdAt,
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to add watched series', { 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 watched series' }, { 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,31 @@ body {
|
||||
animation: toast-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Confirmation Dialog */
|
||||
@keyframes dialog-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-dialog-backdrop {
|
||||
animation: dialog-backdrop-in 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-dialog-panel {
|
||||
animation: dialog-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Hide scrollbar while keeping scroll functional */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
|
||||
+36
-31
@@ -5,12 +5,12 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { useAudiobooks, Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
||||
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
@@ -29,24 +29,20 @@ export default function HomePage() {
|
||||
isLoading: loadingPopular,
|
||||
totalPages: popularTotalPages,
|
||||
message: popularMessage,
|
||||
} = useAudiobooks('popular', 20, popularPage);
|
||||
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
|
||||
|
||||
const {
|
||||
audiobooks: newReleases,
|
||||
isLoading: loadingNewReleases,
|
||||
totalPages: newReleasesTotalPages,
|
||||
message: newReleasesMessage,
|
||||
} = useAudiobooks('new-releases', 20, newReleasesPage);
|
||||
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredPopular = useMemo(
|
||||
() => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular,
|
||||
[popular, hideAvailable]
|
||||
);
|
||||
const filteredNewReleases = useMemo(
|
||||
() => hideAvailable ? newReleases.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : newReleases,
|
||||
[newReleases, hideAvailable]
|
||||
);
|
||||
// Reset to page 1 when hideAvailable changes (total pages may differ)
|
||||
useEffect(() => {
|
||||
setPopularPage(1);
|
||||
setNewReleasesPage(1);
|
||||
}, [hideAvailable]);
|
||||
|
||||
// Handle page changes with auto-scroll to section top
|
||||
const handlePopularPageChange = (page: number) => {
|
||||
@@ -100,7 +96,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={filteredPopular}
|
||||
audiobooks={popular}
|
||||
isLoading={loadingPopular}
|
||||
emptyMessage="No popular audiobooks available"
|
||||
cardSize={cardSize}
|
||||
@@ -145,7 +141,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={filteredNewReleases}
|
||||
audiobooks={newReleases}
|
||||
isLoading={loadingNewReleases}
|
||||
emptyMessage="No new releases available"
|
||||
cardSize={cardSize}
|
||||
@@ -181,22 +177,31 @@ export default function HomePage() {
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Sticky Pagination Controls */}
|
||||
<StickyPagination
|
||||
currentPage={popularPage}
|
||||
totalPages={popularTotalPages}
|
||||
onPageChange={handlePopularPageChange}
|
||||
sectionRef={popularSectionRef}
|
||||
{/* Unified Pagination — single context-aware pill for both sections */}
|
||||
<UnifiedPagination
|
||||
footerRef={footerRef}
|
||||
label="Popular Audiobooks"
|
||||
/>
|
||||
<StickyPagination
|
||||
currentPage={newReleasesPage}
|
||||
totalPages={newReleasesTotalPages}
|
||||
onPageChange={handleNewReleasesPageChange}
|
||||
sectionRef={newReleasesSectionRef}
|
||||
footerRef={footerRef}
|
||||
label="New Releases"
|
||||
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' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -11,7 +11,9 @@ 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';
|
||||
|
||||
const statConfig = [
|
||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||
@@ -139,8 +141,14 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Goodreads Shelves */}
|
||||
<GoodreadsShelvesSection />
|
||||
{/* Generic Shelves Section */}
|
||||
<ShelvesSection />
|
||||
|
||||
{/* Watched Series */}
|
||||
<WatchedSeriesSection />
|
||||
|
||||
{/* Watched Authors */}
|
||||
<WatchedAuthorsSection />
|
||||
|
||||
{/* Active Downloads */}
|
||||
{activeDownloads.length > 0 && (
|
||||
@@ -233,6 +241,9 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* API Tokens */}
|
||||
<ApiTokensSection />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Component: API Docs Endpoint Card
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Expandable card for a single API endpoint with "Try it out" functionality.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ResponseViewer } from './ResponseViewer';
|
||||
import type { EndpointDoc } from '@/lib/constants/api-tokens';
|
||||
|
||||
interface EndpointCardProps {
|
||||
endpoint: EndpointDoc;
|
||||
token: string;
|
||||
useSession: boolean;
|
||||
}
|
||||
|
||||
const METHOD_STYLES: Record<string, string> = {
|
||||
GET: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
|
||||
POST: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
PUT: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
|
||||
DELETE: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',
|
||||
};
|
||||
|
||||
export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<number | null>(null);
|
||||
const [data, setData] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleTryIt = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
setStatus(null);
|
||||
setExpanded(true);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
if (useSession) {
|
||||
// Use session JWT via fetchWithAuth
|
||||
response = await fetchWithAuth(endpoint.path, { method: endpoint.method });
|
||||
} else {
|
||||
// Use custom API token
|
||||
if (!token.trim()) {
|
||||
setError('Please enter an API token');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
response = await fetch(endpoint.path, {
|
||||
method: endpoint.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(response.status);
|
||||
const text = await response.text();
|
||||
setData(text);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Request failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, token, useSession]);
|
||||
|
||||
const methodStyle = METHOD_STYLES[endpoint.method] || METHOD_STYLES.GET;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 shadow-sm overflow-hidden transition-shadow hover:shadow-md">
|
||||
{/* Card header */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold tracking-wide ${methodStyle}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-sm font-mono font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
{endpoint.requiresAdmin && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{endpoint.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{endpoint.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTryIt}
|
||||
disabled={loading}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 transition-all active:scale-[0.97]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
|
||||
Running
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Try it
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable response area */}
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
expanded ? 'max-h-[600px] opacity-100 mt-1' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<ResponseViewer
|
||||
status={status}
|
||||
data={data}
|
||||
error={error}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{(data || error) && !loading && (
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => { setExpanded(false); setData(null); setStatus(null); setError(null); }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Clear response
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Curl example (shown in collapsed footer) */}
|
||||
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-900/30 border-t border-gray-100 dark:border-gray-700/50">
|
||||
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
curl -H "Authorization: Bearer {'<token>'}" {typeof window !== 'undefined' ? window.location.origin : ''}{endpoint.path}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Component: API Docs Response Viewer
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Displays API response with syntax highlighting, status badge, and copy functionality.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface ResponseViewerProps {
|
||||
status: number | null;
|
||||
data: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function statusColor(status: number): string {
|
||||
if (status >= 200 && status < 300) return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300';
|
||||
if (status >= 400 && status < 500) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300';
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300';
|
||||
}
|
||||
|
||||
/** Tokenize JSON string into typed segments for React rendering */
|
||||
type JsonToken = { type: 'string' | 'number' | 'boolean' | 'null' | 'plain'; value: string };
|
||||
|
||||
function tokenizeJson(json: string): JsonToken[] {
|
||||
const tokens: JsonToken[] = [];
|
||||
const regex = /("(?:[^"\\]|\\.)*")|(\b\d+\.?\d*\b)|(\btrue\b|\bfalse\b)|(\bnull\b)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(json)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ type: 'plain', value: json.slice(lastIndex, match.index) });
|
||||
}
|
||||
if (match[1] !== undefined) tokens.push({ type: 'string', value: match[1] });
|
||||
else if (match[2] !== undefined) tokens.push({ type: 'number', value: match[2] });
|
||||
else if (match[3] !== undefined) tokens.push({ type: 'boolean', value: match[3] });
|
||||
else if (match[4] !== undefined) tokens.push({ type: 'null', value: match[4] });
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
if (lastIndex < json.length) {
|
||||
tokens.push({ type: 'plain', value: json.slice(lastIndex) });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const TOKEN_COLORS: Record<JsonToken['type'], string> = {
|
||||
string: 'text-emerald-400',
|
||||
number: 'text-blue-400',
|
||||
boolean: 'text-purple-400',
|
||||
null: 'text-purple-400',
|
||||
plain: 'text-gray-300',
|
||||
};
|
||||
|
||||
export function ResponseViewer({ status, data, error, loading }: ResponseViewerProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
if (!data) return [];
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(data), null, 2);
|
||||
return tokenizeJson(formatted);
|
||||
} catch {
|
||||
return [{ type: 'plain' as const, value: data }];
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(data), null, 2);
|
||||
await navigator.clipboard.writeText(formatted);
|
||||
} catch {
|
||||
await navigator.clipboard.writeText(data);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Sending request...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || status === null) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
Response
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ${statusColor(status)}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* JSON body */}
|
||||
<pre className="p-4 bg-[#0d1117] text-sm font-mono leading-relaxed overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<code>{tokens.map((t, i) => (
|
||||
<span key={i} className={TOKEN_COLORS[t.type]}>{t.value}</span>
|
||||
))}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Component: API Docs Token Input
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Token input field with toggle between custom API token and current session auth.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface TokenInputProps {
|
||||
token: string;
|
||||
onTokenChange: (token: string) => void;
|
||||
useSession: boolean;
|
||||
onUseSessionChange: (useSession: boolean) => void;
|
||||
}
|
||||
|
||||
export function TokenInput({
|
||||
token,
|
||||
onTokenChange,
|
||||
useSession,
|
||||
onUseSessionChange,
|
||||
}: TokenInputProps) {
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Authentication
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Choose how to authenticate your test requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session toggle */}
|
||||
<button
|
||||
onClick={() => onUseSessionChange(!useSession)}
|
||||
className={`
|
||||
relative inline-flex h-7 w-[140px] items-center rounded-full transition-colors duration-200
|
||||
${useSession
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
absolute inset-y-0.5 w-[68px] rounded-full bg-white dark:bg-gray-100 shadow-sm
|
||||
transition-transform duration-200 ease-in-out
|
||||
${useSession ? 'translate-x-[70px]' : 'translate-x-0.5'}
|
||||
`}
|
||||
/>
|
||||
<span
|
||||
className={`
|
||||
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
|
||||
${!useSession ? 'text-gray-900 dark:text-gray-900' : 'text-white/70'}
|
||||
`}
|
||||
>
|
||||
API Token
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
|
||||
${useSession ? 'text-gray-900 dark:text-gray-900' : 'text-gray-500 dark:text-gray-400'}
|
||||
`}
|
||||
>
|
||||
Session
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useSession ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Using your current browser session for authentication
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => onTokenChange(e.target.value)}
|
||||
placeholder="rmab_your_api_token_here"
|
||||
className="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900/50 px-4 py-2.5 pr-20 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{showToken ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ interface AudiobookDetailsModalProps {
|
||||
requestedByUsername?: string | null;
|
||||
hideRequestActions?: boolean;
|
||||
hasReportedIssue?: boolean;
|
||||
aiReason?: string | null;
|
||||
}
|
||||
|
||||
// Status helper
|
||||
@@ -74,6 +75,7 @@ export function AudiobookDetailsModal({
|
||||
requestedByUsername = null,
|
||||
hideRequestActions = false,
|
||||
hasReportedIssue = false,
|
||||
aiReason = null,
|
||||
}: AudiobookDetailsModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
@@ -455,6 +457,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">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { AuthorDetail } from '@/lib/hooks/useAuthors';
|
||||
import { WatchAuthorButton } from '@/components/ui/WatchButton';
|
||||
|
||||
interface AuthorDetailCardProps {
|
||||
author: AuthorDetail;
|
||||
@@ -64,20 +65,27 @@ export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{author.audibleUrl && (
|
||||
<a
|
||||
href={author.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Actions row: Audible link + Watch button */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||
{author.audibleUrl && (
|
||||
<a
|
||||
href={author.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<WatchAuthorButton
|
||||
authorAsin={author.asin}
|
||||
authorName={author.name}
|
||||
coverArtUrl={author.image}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{author.description && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Component: API Tokens Section (Profile Page)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
import type { ApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
export function ApiTokensSection() {
|
||||
const api = useApiTokens<ApiToken>({ basePath: '/api/user/api-tokens' });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
API Tokens
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Create personal API tokens for programmatic access to the API.{' '}
|
||||
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View API documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Error display */}
|
||||
{api.error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{api.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{api.createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{api.createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={api.handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{api.copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss token banner"
|
||||
onClick={api.dismissCreatedToken}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{api.showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<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-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={api.newTokenName}
|
||||
onChange={(e) => api.setNewTokenName(e.target.value)}
|
||||
placeholder="e.g., Home Assistant, Webhook"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && api.handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={api.newTokenExpiry}
|
||||
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => api.handleCreate()}
|
||||
disabled={api.creating || !api.newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{api.creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={api.resetForm}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => api.setShowCreateForm(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
Create New Token
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{api.loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : api.tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{api.tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{api.formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||
disabled={api.deletingId === token.id}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revoke confirmation dialog */}
|
||||
<ConfirmModal
|
||||
isOpen={api.confirmRevokeId !== null}
|
||||
title="Revoke API token"
|
||||
message={
|
||||
<>
|
||||
Are you sure you want to revoke{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</span>
|
||||
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmText="Revoke token"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={api.handleDeleteConfirmed}
|
||||
onClose={() => api.setConfirmRevokeId(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+191
-57
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Component: Goodreads Shelves Section (Profile Page)
|
||||
* Component: Combined Shelves Section (Profile Page)
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
||||
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ManageShelfModal } from '@/components/ui/ManageShelfModal';
|
||||
import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
@@ -26,54 +31,88 @@ function formatRelativeTime(dateStr: string | null): string {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function GoodreadsShelvesSection() {
|
||||
const { shelves, isLoading } = useGoodreadsShelves();
|
||||
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf();
|
||||
export function ShelvesSection() {
|
||||
const { shelves, isLoading } = useShelves();
|
||||
const { deleteShelf: deleteGoodreads, isLoading: isDeletingGoodreads } =
|
||||
useDeleteGoodreadsShelf();
|
||||
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
||||
useDeleteHardcoverShelf();
|
||||
const { squareCovers } = usePreferences();
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (shelfId: string) => {
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [showAddShelf, setShowAddShelf] = useState(false);
|
||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||
const [manageShelf, setManageShelf] = useState<GenericShelf | null>(null);
|
||||
|
||||
const handleDelete = async (shelf: GenericShelf) => {
|
||||
try {
|
||||
await deleteShelf(shelfId);
|
||||
if (shelf.type === 'goodreads') {
|
||||
await deleteGoodreads(shelf.id);
|
||||
} else {
|
||||
await deleteHardcover(shelf.id);
|
||||
}
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10">
|
||||
<svg className="w-[18px] h-[18px] text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-500/10 dark:to-teal-500/10 flex items-center justify-center ring-1 ring-emerald-200/50 dark:ring-emerald-500/10">
|
||||
<svg
|
||||
className="w-[18px] h-[18px] text-emerald-600 dark:text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
|
||||
Goodreads Shelves
|
||||
Shelves
|
||||
</h2>
|
||||
{!isLoading && shelves.length > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected
|
||||
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'}{' '}
|
||||
connected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
{shelves.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAddShelf(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -88,23 +127,30 @@ export function GoodreadsShelvesSection() {
|
||||
squareCovers={squareCovers}
|
||||
isDeleting={isDeleting && confirmDeleteId === shelf.id}
|
||||
isConfirmingDelete={confirmDeleteId === shelf.id}
|
||||
onDelete={() => handleDelete(shelf.id)}
|
||||
onDelete={() => handleDelete(shelf)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onManage={() => setManageShelf(shelf)}
|
||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState onAdd={() => setShowAddModal(true)} />
|
||||
<EmptyState onAdd={() => setShowAddShelf(true)} />
|
||||
)}
|
||||
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
{/* Modals */}
|
||||
<AddShelfModal
|
||||
isOpen={showAddShelf}
|
||||
onClose={() => setShowAddShelf(false)}
|
||||
/>
|
||||
|
||||
<ManageShelfModal
|
||||
isOpen={!!manageShelf}
|
||||
onClose={() => setManageShelf(null)}
|
||||
shelf={manageShelf}
|
||||
/>
|
||||
|
||||
{/* Audiobook Detail Modal (read-only) */}
|
||||
{selectedAsin && (
|
||||
<AudiobookDetailsModal
|
||||
asin={selectedAsin}
|
||||
@@ -122,9 +168,19 @@ export function GoodreadsShelvesSection() {
|
||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700/40 p-10 sm:p-14 text-center">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center mb-5 ring-1 ring-amber-200/50 dark:ring-amber-500/10">
|
||||
<svg className="w-7 h-7 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-500/10 dark:to-teal-500/10 flex items-center justify-center mb-5 ring-1 ring-emerald-200/50 dark:ring-emerald-500/10">
|
||||
<svg
|
||||
className="w-7 h-7 text-emerald-500 dark:text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -132,15 +188,26 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
Connect your reading list
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
|
||||
Link a Goodreads shelf and we'll automatically request the audiobook for every book you add.
|
||||
Link a Goodreads or Hardcover shelf and we'll automatically request the
|
||||
audiobook for every book you add.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Your First Shelf
|
||||
</button>
|
||||
@@ -166,7 +233,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]',
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
|
||||
/>
|
||||
@@ -179,13 +246,14 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
/* ─── Shelf Card ─── */
|
||||
|
||||
interface ShelfCardProps {
|
||||
shelf: GoodreadsShelf;
|
||||
shelf: GenericShelf;
|
||||
squareCovers: boolean;
|
||||
isDeleting: boolean;
|
||||
isConfirmingDelete: boolean;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onManage: () => void;
|
||||
onBookClick: (asin: string) => void;
|
||||
}
|
||||
|
||||
@@ -197,20 +265,44 @@ function ShelfCard({
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onManage,
|
||||
onBookClick,
|
||||
}: ShelfCardProps) {
|
||||
const displayBooks = shelf.books.slice(0, 6);
|
||||
const hasCovers = displayBooks.length > 0;
|
||||
const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length);
|
||||
const remainingCount = Math.max(
|
||||
0,
|
||||
(shelf.bookCount || 0) - displayBooks.length,
|
||||
);
|
||||
const isSyncing = !shelf.lastSyncAt;
|
||||
|
||||
const providerIcon =
|
||||
shelf.type === 'goodreads' ? (
|
||||
<img
|
||||
src="/goodreads-icon.png"
|
||||
alt="Goodreads"
|
||||
className="w-5 h-5 ml-2 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/hardcover-icon.svg"
|
||||
alt="Hardcover"
|
||||
className="w-5 h-5 ml-2 object-contain"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
|
||||
{/* Top: Shelf info + actions */}
|
||||
<div className={cn('flex items-start justify-between', (hasCovers || isSyncing) && 'mb-5')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between',
|
||||
(hasCovers || isSyncing) && 'mb-5',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
|
||||
{shelf.name}
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
|
||||
{shelf.name} {providerIcon}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{shelf.bookCount != null && (
|
||||
@@ -259,22 +351,60 @@ function ShelfCard({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Remove shelf"
|
||||
>
|
||||
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onManage}
|
||||
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
|
||||
title="Manage shelf"
|
||||
aria-label="Manage shelf"
|
||||
>
|
||||
<svg
|
||||
className="w-[18px] h-[18px]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
|
||||
title="Remove shelf"
|
||||
aria-label="Remove shelf"
|
||||
>
|
||||
<svg
|
||||
className="w-[18px] h-[18px]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Stacked book covers */}
|
||||
{hasCovers ? (
|
||||
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
|
||||
<CoverStack
|
||||
books={displayBooks}
|
||||
remainingCount={remainingCount}
|
||||
squareCovers={squareCovers}
|
||||
onBookClick={onBookClick}
|
||||
/>
|
||||
) : isSyncing ? (
|
||||
<div className="flex items-end">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
@@ -282,7 +412,7 @@ function ShelfCard({
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]',
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
|
||||
/>
|
||||
@@ -322,7 +452,7 @@ function CoverStack({
|
||||
'transition-all duration-300 ease-out',
|
||||
hoveredIndex === i && 'scale-[1.18] shadow-xl',
|
||||
coverSize,
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default'
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
style={{
|
||||
marginLeft: i > 0 ? '-16px' : 0,
|
||||
@@ -331,7 +461,11 @@ function CoverStack({
|
||||
onMouseEnter={() => setHoveredIndex(i)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => book.asin && onBookClick(book.asin)}
|
||||
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
|
||||
title={
|
||||
book.asin
|
||||
? `${book.title}${book.author ? ` by ${book.author}` : ''}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
@@ -346,7 +480,7 @@ function CoverStack({
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
coverSize
|
||||
coverSize,
|
||||
)}
|
||||
style={{ marginLeft: '-16px', zIndex: 0 }}
|
||||
>
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Component: Watched Lists Section (Profile Page)
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*
|
||||
* Shows the user's watched series and watched authors on their profile page
|
||||
* with the ability to remove items.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useWatchedSeries, useDeleteWatchedSeries, WatchedSeriesItem } from '@/lib/hooks/useWatchedSeries';
|
||||
import { useWatchedAuthors, useDeleteWatchedAuthor, WatchedAuthorItem } from '@/lib/hooks/useWatchedAuthors';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Series Section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function WatchedSeriesSection() {
|
||||
const router = useRouter();
|
||||
const { series, isLoading } = useWatchedSeries();
|
||||
const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries();
|
||||
const { squareCovers } = usePreferences();
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteSeries(id);
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Series" icon="series" count={null} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => <CardSkeleton key={i} squareCovers={squareCovers} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (series.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Series" icon="series" count={series.length} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{series.map((item) => (
|
||||
<WatchedSeriesCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
squareCovers={squareCovers}
|
||||
isDeleting={isDeleting && confirmDeleteId === item.id}
|
||||
confirmingDelete={confirmDeleteId === item.id}
|
||||
onNavigate={() => router.push(`/series/${item.seriesAsin}`)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(item.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchedSeriesCard({
|
||||
item, squareCovers, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
|
||||
}: {
|
||||
item: WatchedSeriesItem;
|
||||
squareCovers: boolean;
|
||||
isDeleting: boolean;
|
||||
confirmingDelete: boolean;
|
||||
onNavigate: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<button onClick={onNavigate} className="text-left">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors">
|
||||
{item.seriesTitle}
|
||||
</h3>
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Last checked: {formatRelativeTime(item.lastCheckedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
{isDeleting ? '...' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
title="Remove from watched"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Authors Section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function WatchedAuthorsSection() {
|
||||
const router = useRouter();
|
||||
const { authors, isLoading } = useWatchedAuthors();
|
||||
const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor();
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteAuthor(id);
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Authors" icon="author" count={null} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (authors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Authors" icon="author" count={authors.length} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{authors.map((item) => (
|
||||
<WatchedAuthorCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isDeleting={isDeleting && confirmDeleteId === item.id}
|
||||
confirmingDelete={confirmDeleteId === item.id}
|
||||
onNavigate={() => router.push(`/authors/${item.authorAsin}`)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(item.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchedAuthorCard({
|
||||
item, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
|
||||
}: {
|
||||
item: WatchedAuthorItem;
|
||||
isDeleting: boolean;
|
||||
confirmingDelete: boolean;
|
||||
onNavigate: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
|
||||
{/* Avatar */}
|
||||
<button onClick={onNavigate} className="flex-shrink-0">
|
||||
<div className="relative w-14 h-14 rounded-full overflow-hidden bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900">
|
||||
{item.coverArtUrl ? (
|
||||
<Image src={item.coverArtUrl} alt={item.authorName} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0 flex items-center">
|
||||
<div>
|
||||
<button onClick={onNavigate} className="text-left">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{item.authorName}
|
||||
</h3>
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Last checked: {formatRelativeTime(item.lastCheckedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
{isDeleting ? '...' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
title="Remove from watched"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeader({ title, icon, count }: { title: string; icon: 'series' | 'author'; count: number | null }) {
|
||||
const gradientColors = icon === 'series'
|
||||
? 'from-emerald-500 to-teal-500'
|
||||
: 'from-blue-500 to-indigo-500';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className={`w-1 h-6 bg-gradient-to-b ${gradientColors} rounded-full`} />
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
{count !== null && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">({count})</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardSkeleton({ squareCovers }: { squareCovers?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 animate-pulse">
|
||||
<div className={`w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg bg-gray-200 dark:bg-gray-700`} />
|
||||
<div className="flex-1 space-y-2 py-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,11 +41,8 @@ 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 requestType = request.type || 'audiobook';
|
||||
@@ -57,10 +52,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
// Ebook requests don't support interactive search (Anna's Archive only)
|
||||
// Interactive search also requires the interactiveSearch permission
|
||||
const hasInteractiveSearchAccess = user?.role === 'admin' || user?.permissions?.interactiveSearch !== false;
|
||||
const canSearch = hasInteractiveSearchAccess && !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
||||
@@ -72,20 +63,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();
|
||||
@@ -255,27 +232,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 +249,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
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
||||
import { WatchSeriesButton } from '@/components/ui/WatchButton';
|
||||
|
||||
interface SeriesDetailCardProps {
|
||||
series: SeriesDetail;
|
||||
@@ -91,20 +92,27 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{series.audibleUrl && (
|
||||
<a
|
||||
href={series.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Actions row: Audible link + Watch button */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||
{series.audibleUrl && (
|
||||
<a
|
||||
href={series.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<WatchSeriesButton
|
||||
seriesAsin={series.asin}
|
||||
seriesTitle={series.title}
|
||||
coverArtUrl={series.books[0]?.coverArtUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{series.description && (
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Component: Add Goodreads Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
|
||||
interface AddGoodreadsShelfModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||
|
||||
export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) {
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const { addShelf, isLoading, error } = useAddGoodreadsShelf();
|
||||
|
||||
const validateUrl = (url: string): boolean => {
|
||||
if (!url.trim()) {
|
||||
setValidationError('RSS URL is required');
|
||||
return false;
|
||||
}
|
||||
if (!GOODREADS_RSS_PATTERN.test(url)) {
|
||||
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
|
||||
return false;
|
||||
}
|
||||
setValidationError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateUrl(rssUrl)) return;
|
||||
|
||||
try {
|
||||
const shelf = await addShelf(rssUrl);
|
||||
setSuccess(true);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRssUrl('');
|
||||
setValidationError('');
|
||||
setSuccess(false);
|
||||
setSuccessMessage('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Goodreads Shelf" size="sm">
|
||||
<div className="space-y-5">
|
||||
{/* Visual header */}
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.5 8.257a4.5 4.5 0 007.244 1.242" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success alert */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<Input
|
||||
type="url"
|
||||
label="Goodreads RSS URL"
|
||||
value={rssUrl}
|
||||
onChange={(e) => {
|
||||
setRssUrl(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
error={validationError}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
||||
Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isLoading}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Add Shelf
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Component: Add Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { HardcoverForm } from './HardcoverForm';
|
||||
|
||||
interface AddShelfModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||
|
||||
export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
||||
const [provider, setProvider] = useState<'goodreads' | 'hardcover'>('goodreads');
|
||||
|
||||
// Goodreads State
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
|
||||
// Hardcover State
|
||||
const [apiToken, setApiToken] = useState('');
|
||||
const [listType, setListType] = useState<'status' | 'custom'>('status');
|
||||
const [statusId, setStatusId] = useState('1');
|
||||
const [customListId, setCustomListId] = useState('');
|
||||
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
const { addShelf: addGoodreads, isLoading: isGoodreadsLoading, error: goodreadsError } = useAddGoodreadsShelf();
|
||||
const { addShelf: addHardcover, isLoading: isHardcoverLoading, error: hardcoverError } = useAddHardcoverShelf();
|
||||
|
||||
const isLoading = isGoodreadsLoading || isHardcoverLoading;
|
||||
const currentError = provider === 'goodreads' ? goodreadsError : hardcoverError;
|
||||
|
||||
const validateInput = (): boolean => {
|
||||
if (provider === 'goodreads') {
|
||||
if (!rssUrl.trim()) {
|
||||
setValidationError('RSS URL is required');
|
||||
return false;
|
||||
}
|
||||
if (!GOODREADS_RSS_PATTERN.test(rssUrl)) {
|
||||
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!apiToken.trim()) {
|
||||
setValidationError('Hardcover API Token is required');
|
||||
return false;
|
||||
}
|
||||
if (listType === 'custom' && !customListId.trim()) {
|
||||
setValidationError('Hardcover List URL or Slug is required');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setValidationError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validateInput()) return;
|
||||
|
||||
try {
|
||||
if (provider === 'goodreads') {
|
||||
const shelf = await addGoodreads(rssUrl);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
} else {
|
||||
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
|
||||
const shelf = await addHardcover(apiToken.trim(), finalId);
|
||||
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
|
||||
setApiToken('');
|
||||
setCustomListId('');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Error is handled by the hooks
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRssUrl('');
|
||||
setApiToken('');
|
||||
setCustomListId('');
|
||||
setValidationError('');
|
||||
setSuccess(false);
|
||||
setSuccessMessage('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Shelf" size="sm">
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Provider Tabs */}
|
||||
<div className="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${
|
||||
provider === 'goodreads'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
onClick={() => { setProvider('goodreads'); setValidationError(''); }}
|
||||
>
|
||||
Goodreads
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${
|
||||
provider === 'hardcover'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
onClick={() => { setProvider('hardcover'); setValidationError(''); }}
|
||||
>
|
||||
Hardcover
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Visual Header */}
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
||||
{provider === 'goodreads' ? (
|
||||
<>
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
|
||||
<img src="/goodreads-icon.png" alt="Goodreads" className="w-5 h-5 object-contain" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Paste your Goodreads shelf RSS URL. Books will be automatically requested.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center ring-1 ring-indigo-200/50 dark:ring-indigo-500/10 flex-shrink-0">
|
||||
<img src="/hardcover-icon.svg" alt="Hardcover" className="w-6 h-6 object-contain" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Connect a Hardcover reading list and books will be automatically requested as you add them.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success Alert */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
{currentError && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">{currentError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{provider === 'goodreads' ? (
|
||||
<div>
|
||||
<Input
|
||||
type="url"
|
||||
label="Goodreads RSS URL"
|
||||
value={rssUrl}
|
||||
onChange={(e) => { setRssUrl(e.target.value); if (validationError) setValidationError(''); }}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
error={validationError}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
||||
Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<HardcoverForm
|
||||
apiToken={apiToken}
|
||||
setApiToken={setApiToken}
|
||||
listType={listType}
|
||||
setListType={setListType}
|
||||
statusId={statusId}
|
||||
setStatusId={setStatusId}
|
||||
customListId={customListId}
|
||||
setCustomListId={setCustomListId}
|
||||
validationError={validationError}
|
||||
setValidationError={setValidationError}
|
||||
isLoading={isLoading}
|
||||
success={success}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleClose} disabled={isLoading || success}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" loading={isLoading} disabled={isLoading || success}>
|
||||
Add Shelf
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface ConfirmModalProps {
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
message: string | React.ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isLoading?: boolean;
|
||||
@@ -35,7 +35,9 @@ export function ConfirmModal({
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
|
||||
<div className="space-y-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">{message}</p>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{typeof message === 'string' ? <p>{message}</p> : message}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button onClick={onClose} variant="outline" disabled={isLoading}>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Component: Hardcover Shelf Form
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from './Input';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status option definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{
|
||||
id: '1',
|
||||
label: 'Want to Read',
|
||||
description: 'Books saved to read later',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: 'Currently Reading',
|
||||
description: 'Books actively being read',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: 'Read',
|
||||
description: 'Books already finished',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
label: 'Did Not Finish',
|
||||
description: 'Books started but set aside',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HardcoverFormProps {
|
||||
apiToken: string;
|
||||
setApiToken: (v: string) => void;
|
||||
listType: 'status' | 'custom';
|
||||
setListType: (v: 'status' | 'custom') => void;
|
||||
statusId: string;
|
||||
setStatusId: (v: string) => void;
|
||||
customListId: string;
|
||||
setCustomListId: (v: string) => void;
|
||||
validationError: string;
|
||||
setValidationError: (v: string) => void;
|
||||
isLoading: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function HardcoverForm({
|
||||
apiToken, setApiToken,
|
||||
listType, setListType,
|
||||
statusId, setStatusId,
|
||||
customListId, setCustomListId,
|
||||
validationError, setValidationError,
|
||||
isLoading, success,
|
||||
}: HardcoverFormProps) {
|
||||
const disabled = isLoading || success;
|
||||
const isTokenError = validationError === 'Hardcover API Token is required';
|
||||
const isListError = !isTokenError && !!validationError;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* API Token */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
API Token
|
||||
</label>
|
||||
<a
|
||||
href="https://hardcover.app/account/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition-colors flex items-center gap-1 group"
|
||||
>
|
||||
Get your token
|
||||
<svg className="w-3 h-3 opacity-60 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => {
|
||||
setApiToken(e.target.value);
|
||||
if (isTokenError) setValidationError('');
|
||||
}}
|
||||
placeholder="Paste your Hardcover API token"
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'block w-full rounded-lg border px-4 py-2 text-sm transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'bg-white dark:bg-gray-800/60 text-gray-900 dark:text-white',
|
||||
'placeholder-gray-400 dark:placeholder-gray-500',
|
||||
isTokenError
|
||||
? 'border-red-400 dark:border-red-500'
|
||||
: 'border-gray-200 dark:border-gray-700',
|
||||
].join(' ')}
|
||||
/>
|
||||
{isTokenError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{validationError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
|
||||
Found under{' '}
|
||||
<span className="font-medium text-gray-500 dark:text-gray-400">Settings → API</span>
|
||||
{' '}on hardcover.app. Stored securely and never shared.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700/60" />
|
||||
|
||||
{/* List Type Selection */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Which list should we watch?
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Choose a reading status or one of your custom lists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<ListTypeCard
|
||||
active={listType === 'status'}
|
||||
onClick={() => setListType('status')}
|
||||
disabled={disabled}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />
|
||||
</svg>
|
||||
}
|
||||
title="Reading Status"
|
||||
subtitle="Want to Read, Reading, Read, etc."
|
||||
/>
|
||||
<ListTypeCard
|
||||
active={listType === 'custom'}
|
||||
onClick={() => setListType('custom')}
|
||||
disabled={disabled}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
}
|
||||
title="Custom List"
|
||||
subtitle="A list you created on Hardcover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status picker or Custom list input */}
|
||||
{listType === 'status' ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Status to sync</p>
|
||||
<div className="space-y-1.5">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<StatusRow
|
||||
key={opt.id}
|
||||
opt={opt}
|
||||
selected={statusId === opt.id}
|
||||
onSelect={() => setStatusId(opt.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
label="List URL or Slug"
|
||||
value={customListId}
|
||||
onChange={(e) => {
|
||||
setCustomListId(e.target.value);
|
||||
if (isListError) setValidationError('');
|
||||
}}
|
||||
placeholder="https://hardcover.app/@username/lists/..."
|
||||
error={isListError ? validationError : ''}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
|
||||
Paste the list URL from Hardcover, or enter just the slug (e.g.{' '}
|
||||
<code className="font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700/60 px-1 py-0.5 rounded text-[11px]">my-audiobooks</code>
|
||||
) or a numeric ID.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListTypeCard({
|
||||
active, onClick, disabled, icon, title, subtitle,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'relative text-left p-3 rounded-xl border-2 transition-all duration-150',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
active
|
||||
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50/70 dark:bg-indigo-500/[0.08]'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800/60',
|
||||
].join(' ')}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute top-2.5 right-2.5 w-2 h-2 rounded-full bg-indigo-500 dark:bg-indigo-400" />
|
||||
)}
|
||||
<div className={[
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center mb-2',
|
||||
active
|
||||
? 'bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
|
||||
].join(' ')}>
|
||||
{icon}
|
||||
</div>
|
||||
<p className={`text-sm font-medium leading-tight ${active ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{title}
|
||||
</p>
|
||||
<p className={`text-xs mt-0.5 leading-snug ${active ? 'text-indigo-500/80 dark:text-indigo-400/70' : 'text-gray-400 dark:text-gray-500'}`}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
opt, selected, onSelect, disabled,
|
||||
}: {
|
||||
opt: typeof STATUS_OPTIONS[number];
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-150 text-left',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
selected
|
||||
? 'border-indigo-400/70 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-500/[0.08]'
|
||||
: 'border-gray-200 dark:border-gray-700/80 bg-white dark:bg-gray-800/30 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/80 dark:hover:bg-gray-800/50',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={`flex-shrink-0 ${selected ? 'text-indigo-500 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500'}`}>
|
||||
{opt.icon}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className={`block text-sm font-medium ${selected ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{opt.description}
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-indigo-500 dark:text-indigo-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Component: Manage Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { GenericShelf } from '@/lib/hooks/useShelves';
|
||||
import { useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ManageShelfModalProps {
|
||||
shelf: GenericShelf | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) {
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
const [listId, setListId] = useState('');
|
||||
const [apiToken, setApiToken] = useState('');
|
||||
|
||||
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf();
|
||||
const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf();
|
||||
|
||||
// Reset form when shelf changes (use shelf?.id for stable reference)
|
||||
React.useEffect(() => {
|
||||
if (shelf) {
|
||||
setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : '');
|
||||
setListId(shelf.type === 'hardcover' ? shelf.sourceId : '');
|
||||
setApiToken('');
|
||||
}
|
||||
}, [shelf?.id]);
|
||||
|
||||
if (!shelf) return null;
|
||||
|
||||
const isUpdating = isUpdatingGoodreads || isUpdatingHardcover;
|
||||
const currentError = shelf.type === 'goodreads' ? goodreadsError : hardcoverError;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (shelf.type === 'goodreads') {
|
||||
if (!rssUrl.trim()) return;
|
||||
await updateGoodreads(shelf.id, rssUrl.trim());
|
||||
} else {
|
||||
if (!listId.trim()) return;
|
||||
await updateHardcover(shelf.id, {
|
||||
listId: listId.trim(),
|
||||
apiToken: apiToken.trim() || undefined,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// Error is handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
const isGoodreads = shelf.type === 'goodreads';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Manage ${shelf.name}`}>
|
||||
<div className="space-y-6">
|
||||
{currentError && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{currentError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{isGoodreads ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Goodreads RSS URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={rssUrl}
|
||||
onChange={(e) => setRssUrl(e.target.value)}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 dark:focus:ring-emerald-400 dark:text-white transition-colors"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Hardcover List ID or Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={listId}
|
||||
onChange={(e) => setListId(e.target.value)}
|
||||
placeholder="e.g., 1234, want-to-read, status-1"
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New API Token <span className="text-gray-400 dark:text-gray-500 font-normal">(Leave blank to keep current)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
placeholder="Paste your Hardcover token here..."
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUpdating}
|
||||
className={cn(
|
||||
'px-6 py-2 text-sm font-medium text-white rounded-xl shadow-sm transition-colors',
|
||||
isGoodreads
|
||||
? 'bg-amber-600 hover:bg-amber-700'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700',
|
||||
isUpdating && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Update & Re-sync'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Component: Sticky Pagination with Progress Bar
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface StickyPaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
sectionRef: React.RefObject<HTMLElement | null>;
|
||||
label: string; // e.g., "Popular Audiobooks"
|
||||
footerRef?: React.RefObject<HTMLElement | null>; // Optional footer ref to avoid overlap
|
||||
}
|
||||
|
||||
export function StickyPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
sectionRef,
|
||||
label,
|
||||
footerRef,
|
||||
}: StickyPaginationProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isFooterVisible, setIsFooterVisible] = useState(false);
|
||||
const [jumpPage, setJumpPage] = useState(currentPage.toString());
|
||||
|
||||
// Update jump page input when current page changes externally
|
||||
useEffect(() => {
|
||||
setJumpPage(currentPage.toString());
|
||||
}, [currentPage]);
|
||||
|
||||
// Intersection Observer to show/hide pagination based on section visibility
|
||||
useEffect(() => {
|
||||
if (!sectionRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// Show pagination when section is in viewport
|
||||
setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1);
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.1, 0.5, 1],
|
||||
rootMargin: '-60px 0px -60px 0px', // Account for header/footer
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(sectionRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [sectionRef]);
|
||||
|
||||
// Footer observer to hide pagination when footer is visible
|
||||
useEffect(() => {
|
||||
if (!footerRef?.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// Hide pagination when footer is in viewport
|
||||
setIsFooterVisible(entry.isIntersecting);
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.1],
|
||||
rootMargin: '0px',
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(footerRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [footerRef]);
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentPage > 1) {
|
||||
onPageChange(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentPage < totalPages) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const page = parseInt(jumpPage, 10);
|
||||
if (!isNaN(page) && page >= 1 && page <= totalPages) {
|
||||
onPageChange(page);
|
||||
} else {
|
||||
// Reset to current page if invalid
|
||||
setJumpPage(currentPage.toString());
|
||||
}
|
||||
};
|
||||
|
||||
// Final visibility: show when section is visible AND footer is not visible
|
||||
const shouldShow = isVisible && !isFooterVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
|
||||
shouldShow ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Section Label - Hidden on small screens */}
|
||||
<div className="hidden md:block text-xs font-medium text-gray-600 dark:text-gray-400 pr-2 border-r border-gray-300 dark:border-gray-600">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Page Info & Jump */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
Page
|
||||
</span>
|
||||
<form onSubmit={handleJumpSubmit} className="inline-flex">
|
||||
<input
|
||||
type="text"
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onBlur={handleJumpSubmit}
|
||||
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded
|
||||
bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
border border-gray-300 dark:border-gray-600
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
||||
aria-label="Current page"
|
||||
/>
|
||||
</form>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
of {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export interface PaginationSection {
|
||||
/** Display label, e.g. "Popular Audiobooks" */
|
||||
label: string;
|
||||
/** Tailwind color class applied to the active accent dot, e.g. "bg-blue-500" */
|
||||
accentColor: string;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
/** Ref to the section element — used for intersection tracking */
|
||||
sectionRef: React.RefObject<HTMLElement | null>;
|
||||
/** Called when user clicks this section's dot while it's inactive — should scroll to section */
|
||||
onScrollToSection: () => void;
|
||||
}
|
||||
|
||||
interface UnifiedPaginationProps {
|
||||
sections: [PaginationSection, PaginationSection];
|
||||
footerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small page-jump form — isolated to prevent key re-mounts on section switch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PageJumpProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) {
|
||||
const [value, setValue] = useState(currentPage.toString());
|
||||
|
||||
// Sync when page changes externally (e.g. after scrollIntoView + setState)
|
||||
useEffect(() => {
|
||||
setValue(currentPage.toString());
|
||||
}, [currentPage]);
|
||||
|
||||
const commit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!isNaN(parsed) && parsed >= 1 && parsed <= totalPages) {
|
||||
onPageChange(parsed);
|
||||
} else {
|
||||
setValue(currentPage.toString());
|
||||
}
|
||||
},
|
||||
[value, currentPage, totalPages, onPageChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||
Page
|
||||
</span>
|
||||
<form onSubmit={commit} className="inline-flex">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded-md
|
||||
bg-black/[0.04] dark:bg-white/[0.08]
|
||||
text-gray-900 dark:text-gray-100
|
||||
border border-gray-300/60 dark:border-white/10
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent
|
||||
transition-all duration-150"
|
||||
aria-label="Jump to page"
|
||||
/>
|
||||
</form>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||
of {totalPages}
|
||||
</span>
|
||||
</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 [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 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;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Track which section each instance belongs to via intersection ratio
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = [];
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
if (!section.sectionRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio;
|
||||
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05;
|
||||
|
||||
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;
|
||||
|
||||
setActiveIndex((current) => {
|
||||
if (current !== dominant) {
|
||||
// Trigger cross-fade transition
|
||||
setIsTransitioning(true);
|
||||
|
||||
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',
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(section.sectionRef.current);
|
||||
observers.push(observer);
|
||||
});
|
||||
|
||||
return () => {
|
||||
observers.forEach((o) => o.disconnect());
|
||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sections[0].sectionRef, sections[1].sectionRef]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Footer observer
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (!footerRef?.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setFooterVisible(entry.isIntersecting),
|
||||
{ threshold: [0, 0.01] }
|
||||
);
|
||||
observer.observe(footerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [footerRef]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derived values for the currently active section
|
||||
// ------------------------------------------------------------------
|
||||
const active = sections[activeIndex];
|
||||
|
||||
const handlePrev = () => {
|
||||
if (active.currentPage > 1) active.onPageChange(active.currentPage - 1);
|
||||
};
|
||||
const handleNext = () => {
|
||||
if (active.currentPage < active.totalPages) active.onPageChange(active.currentPage + 1);
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Render
|
||||
// ------------------------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
fixed bottom-6 left-1/2 -translate-x-1/2 z-40
|
||||
transition-all duration-300 ease-out
|
||||
${shouldShow
|
||||
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||
: 'translate-y-4 opacity-0 pointer-events-none'
|
||||
}
|
||||
`}
|
||||
aria-hidden={!shouldShow}
|
||||
>
|
||||
{/* Pill surface */}
|
||||
<div
|
||||
className="
|
||||
flex items-center gap-0
|
||||
bg-white/90 dark:bg-gray-900/90
|
||||
backdrop-blur-xl
|
||||
rounded-full
|
||||
shadow-[0_8px_32px_rgba(0,0,0,0.12),0_2px_8px_rgba(0,0,0,0.08)]
|
||||
dark:shadow-[0_8px_32px_rgba(0,0,0,0.4),0_2px_8px_rgba(0,0,0,0.3)]
|
||||
border border-gray-200/60 dark:border-white/[0.08]
|
||||
px-1.5 py-1.5
|
||||
overflow-hidden
|
||||
"
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
{/* 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
|
||||
className={`
|
||||
flex items-center gap-3
|
||||
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">
|
||||
{active.label}
|
||||
</span>
|
||||
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={active.currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
className="
|
||||
p-1.5 rounded-full
|
||||
text-gray-600 dark:text-gray-300
|
||||
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||
active:scale-95
|
||||
disabled:opacity-25 disabled:cursor-not-allowed
|
||||
transition-all duration-150
|
||||
"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{/* Page jump */}
|
||||
<PageJump
|
||||
currentPage={active.currentPage}
|
||||
totalPages={active.totalPages}
|
||||
onPageChange={active.onPageChange}
|
||||
/>
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={active.currentPage === active.totalPages}
|
||||
aria-label="Next page"
|
||||
className="
|
||||
p-1.5 rounded-full
|
||||
text-gray-600 dark:text-gray-300
|
||||
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||
active:scale-95
|
||||
disabled:opacity-25 disabled:cursor-not-allowed
|
||||
transition-all duration-150
|
||||
"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right padding balance */}
|
||||
<div className="w-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Component: Watch Button (Series / Author)
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*
|
||||
* Reusable toggle button for watching/unwatching a series or author.
|
||||
* Shows a confirmation modal before watching. Unwatching is instant.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useWatchedSeries, useAddWatchedSeries, useDeleteWatchedSeries } from '@/lib/hooks/useWatchedSeries';
|
||||
import { useWatchedAuthors, useAddWatchedAuthor, useDeleteWatchedAuthor } from '@/lib/hooks/useWatchedAuthors';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
interface WatchSeriesButtonProps {
|
||||
seriesAsin: string;
|
||||
seriesTitle: string;
|
||||
coverArtUrl?: string;
|
||||
}
|
||||
|
||||
export function WatchSeriesButton({ seriesAsin, seriesTitle, coverArtUrl }: WatchSeriesButtonProps) {
|
||||
const { series } = useWatchedSeries();
|
||||
const { addSeries, isLoading: isAdding } = useAddWatchedSeries();
|
||||
const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const watchedEntry = series.find((s) => s.seriesAsin === seriesAsin);
|
||||
const isWatching = !!watchedEntry;
|
||||
const isLoading = isAdding || isDeleting;
|
||||
|
||||
const handleClick = async () => {
|
||||
setError(null);
|
||||
if (isWatching && watchedEntry) {
|
||||
// Unwatch immediately (no confirmation needed)
|
||||
try {
|
||||
await deleteSeries(watchedEntry.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
} else {
|
||||
// Show confirmation before watching
|
||||
setShowConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmWatch = async () => {
|
||||
setShowConfirm(false);
|
||||
setError(null);
|
||||
try {
|
||||
await addSeries(seriesAsin, seriesTitle, coverArtUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col items-start">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||
isWatching
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 border border-emerald-200 dark:border-emerald-700/50'
|
||||
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 hover:text-emerald-700 dark:hover:text-emerald-300 border border-gray-200 dark:border-gray-600/50 hover:border-emerald-200 dark:hover:border-emerald-700/50'
|
||||
} ${isLoading ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : isWatching ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isWatching ? 'Watching' : 'Watch Series'}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-1">{error}</span>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
onClose={() => setShowConfirm(false)}
|
||||
onConfirm={handleConfirmWatch}
|
||||
title={`Watch "${seriesTitle}"?`}
|
||||
message={`This will request all books in "${seriesTitle}" that aren't already in your library, and automatically request new releases as they're added to the series. Continue?`}
|
||||
confirmText="Watch"
|
||||
isLoading={isAdding}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WatchAuthorButtonProps {
|
||||
authorAsin: string;
|
||||
authorName: string;
|
||||
coverArtUrl?: string;
|
||||
}
|
||||
|
||||
export function WatchAuthorButton({ authorAsin, authorName, coverArtUrl }: WatchAuthorButtonProps) {
|
||||
const { authors } = useWatchedAuthors();
|
||||
const { addAuthor, isLoading: isAdding } = useAddWatchedAuthor();
|
||||
const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const watchedEntry = authors.find((a) => a.authorAsin === authorAsin);
|
||||
const isWatching = !!watchedEntry;
|
||||
const isLoading = isAdding || isDeleting;
|
||||
|
||||
const handleClick = async () => {
|
||||
setError(null);
|
||||
if (isWatching && watchedEntry) {
|
||||
// Unwatch immediately (no confirmation needed)
|
||||
try {
|
||||
await deleteAuthor(watchedEntry.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
} else {
|
||||
// Show confirmation before watching
|
||||
setShowConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmWatch = async () => {
|
||||
setShowConfirm(false);
|
||||
setError(null);
|
||||
try {
|
||||
await addAuthor(authorAsin, authorName, coverArtUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col items-start">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||
isWatching
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 border border-blue-200 dark:border-blue-700/50'
|
||||
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-700 dark:hover:text-blue-300 border border-gray-200 dark:border-gray-600/50 hover:border-blue-200 dark:hover:border-blue-700/50'
|
||||
} ${isLoading ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : isWatching ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isWatching ? 'Watching' : 'Watch Author'}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-1">{error}</span>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
onClose={() => setShowConfirm(false)}
|
||||
onConfirm={handleConfirmWatch}
|
||||
title={`Watch "${authorName}"?`}
|
||||
message={`This will request all books by "${authorName}" that aren't already in your library, and automatically request new releases. Continue?`}
|
||||
confirmText="Watch"
|
||||
isLoading={isAdding}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Component: API Token Constants
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Centralized API token constants used across authentication middleware and token routes.
|
||||
*/
|
||||
|
||||
/** Prefix prepended to all generated API tokens for identification */
|
||||
export const API_TOKEN_PREFIX = 'rmab_';
|
||||
|
||||
/** Number of random bytes used to generate the token's random portion */
|
||||
export const TOKEN_RANDOM_BYTES = 32;
|
||||
|
||||
/** Length of the token prefix stored in the database for display (first 12 chars: "rmab_" + 7 hex chars) */
|
||||
export const TOKEN_PREFIX_LENGTH = 12;
|
||||
|
||||
/** Maximum number of active (non-expired) API tokens a single user may hold */
|
||||
export const MAX_TOKENS_PER_USER = 25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoint allowlist — restricts which routes API tokens may access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape of an allowed endpoint entry */
|
||||
export interface AllowedEndpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Extended metadata used by the interactive API docs page */
|
||||
export interface EndpointDoc {
|
||||
method: string;
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
requiresAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that API tokens are permitted to call.
|
||||
* JWT-authenticated sessions are NOT restricted by this list.
|
||||
*/
|
||||
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
|
||||
{ method: 'GET', path: '/api/auth/me' },
|
||||
{ method: 'GET', path: '/api/requests' },
|
||||
{ method: 'GET', path: '/api/admin/metrics' },
|
||||
{ method: 'GET', path: '/api/admin/downloads/active' },
|
||||
{ method: 'GET', path: '/api/admin/requests/recent' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Full documentation metadata for each allowed endpoint.
|
||||
* Consumed by the /api-docs interactive page.
|
||||
*/
|
||||
export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/auth/me',
|
||||
title: 'Get current user',
|
||||
description:
|
||||
'Returns the authenticated user\'s profile information including username, role, and account details.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/requests',
|
||||
title: 'List requests',
|
||||
description:
|
||||
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/metrics',
|
||||
title: 'System metrics',
|
||||
description:
|
||||
'Returns system health metrics including request counts, download statistics, and library size.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/downloads/active',
|
||||
title: 'Active downloads',
|
||||
description:
|
||||
'Returns currently active downloads including progress, speed, and ETA.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/requests/recent',
|
||||
title: 'Recent requests',
|
||||
description:
|
||||
'Returns the most recent audiobook requests across all users.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check whether a given method + path is on the API token allowlist.
|
||||
* Method comparison is case-insensitive.
|
||||
*/
|
||||
export function isEndpointAllowed(method: string, path: string): boolean {
|
||||
const upperMethod = method.toUpperCase();
|
||||
return API_TOKEN_ALLOWED_ENDPOINTS.some(
|
||||
(ep) => ep.method === upperMethod && ep.path === path
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Component: Shelf Hook Factory
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Generic hook factory for shelf CRUD operations. Each provider (Goodreads,
|
||||
* Hardcover, etc.) calls this with its API endpoint to get fully typed hooks
|
||||
* without duplicating the SWR/fetch/mutate boilerplate.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
export interface ShelfBook {
|
||||
coverUrl: string;
|
||||
asin: string | null;
|
||||
title: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
/**
|
||||
* Invalidate both the provider-specific endpoint and the combined /api/user/shelves endpoint.
|
||||
*/
|
||||
function revalidate(endpoint: string) {
|
||||
mutate((key) => typeof key === 'string' && key.includes(endpoint));
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/shelves'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a set of hooks for a shelf provider endpoint.
|
||||
*
|
||||
* Returns:
|
||||
* - useList: SWR-based hook to list shelves
|
||||
* - useAdd: Hook returning { addShelf(body), isLoading, error }
|
||||
* - useDelete: Hook returning { deleteShelf(id), isLoading, error }
|
||||
* - useUpdate: Hook returning { updateShelf(id, body), isLoading, error }
|
||||
*/
|
||||
export function createShelfHooks<TShelf>(endpoint: string) {
|
||||
function useList() {
|
||||
const { accessToken } = useAuth();
|
||||
const key = accessToken ? endpoint : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(key, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as TShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function useAdd() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addShelf = async (body: Record<string, unknown>) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to add shelf');
|
||||
}
|
||||
|
||||
revalidate(endpoint);
|
||||
return data.shelf as TShelf;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
}
|
||||
|
||||
function useDelete() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deleteShelf = async (shelfId: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to remove shelf');
|
||||
}
|
||||
|
||||
revalidate(endpoint);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteShelf, isLoading, error };
|
||||
}
|
||||
|
||||
function useUpdate() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const updateShelf = async (shelfId: string, body: Record<string, unknown>) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to update shelf');
|
||||
}
|
||||
|
||||
revalidate(endpoint);
|
||||
return data.shelf as TShelf;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { updateShelf, isLoading, error };
|
||||
}
|
||||
|
||||
return { useList, useAdd, useDelete, useUpdate };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Component: Shared API Token Management Hook
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { ApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
/** Typed request body for creating an API token */
|
||||
export interface CreateTokenBody {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UseApiTokensConfig {
|
||||
/** Base API path, e.g. '/api/admin/api-tokens' or '/api/user/api-tokens' */
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export interface UseApiTokensReturn<T extends ApiToken = ApiToken> {
|
||||
tokens: T[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
newTokenName: string;
|
||||
setNewTokenName: (name: string) => void;
|
||||
newTokenExpiry: string;
|
||||
setNewTokenExpiry: (expiry: string) => void;
|
||||
showCreateForm: boolean;
|
||||
setShowCreateForm: (show: boolean) => void;
|
||||
createdToken: string | null;
|
||||
copied: boolean;
|
||||
deletingId: string | null;
|
||||
confirmRevokeId: string | null;
|
||||
setConfirmRevokeId: (id: string | null) => void;
|
||||
fetchTokens: () => Promise<void>;
|
||||
handleCreate: (extraBody?: Partial<CreateTokenBody>) => Promise<boolean>;
|
||||
handleDeleteConfirmed: () => Promise<void>;
|
||||
handleCopy: () => Promise<void>;
|
||||
dismissCreatedToken: () => void;
|
||||
resetForm: () => void;
|
||||
formatDate: (dateStr: string | null) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook for API token CRUD operations.
|
||||
* Used by both the admin ApiTab and the user ApiTokensSection.
|
||||
*/
|
||||
export function useApiTokens<T extends ApiToken = ApiToken>(
|
||||
config: UseApiTokensConfig
|
||||
): UseApiTokensReturn<T> {
|
||||
const [tokens, setTokens] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(config.basePath);
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to load API tokens';
|
||||
try {
|
||||
const data = await response.json();
|
||||
message = data.error || message;
|
||||
} catch {
|
||||
// Keep default message when response body is not JSON
|
||||
}
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTokens(data.tokens);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Failed to load API tokens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [config.basePath]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTokens();
|
||||
}, [fetchTokens]);
|
||||
|
||||
const computeExpiresAt = (): string | null => {
|
||||
if (newTokenExpiry === 'never') return null;
|
||||
const date = new Date();
|
||||
switch (newTokenExpiry) {
|
||||
case '30d': date.setDate(date.getDate() + 30); break;
|
||||
case '90d': date.setDate(date.getDate() + 90); break;
|
||||
case '1y': date.setFullYear(date.getFullYear() + 1); break;
|
||||
}
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const handleCreate = async (extraBody?: Partial<CreateTokenBody>) => {
|
||||
if (!newTokenName.trim()) {
|
||||
setError('Token name is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const body: CreateTokenBody = {
|
||||
name: newTokenName.trim(),
|
||||
expiresAt: computeExpiresAt(),
|
||||
...extraBody,
|
||||
};
|
||||
|
||||
const response = await fetchWithAuth(config.basePath, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCreatedToken(data.fullToken);
|
||||
setNewTokenName('');
|
||||
setNewTokenExpiry('never');
|
||||
setShowCreateForm(false);
|
||||
await fetchTokens();
|
||||
return true;
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to create token');
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to create token');
|
||||
return false;
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmed = async () => {
|
||||
const id = confirmRevokeId;
|
||||
if (!id) return;
|
||||
|
||||
setConfirmRevokeId(null);
|
||||
setDeletingId(id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${config.basePath}/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTokens(tokens.filter((t) => t.id !== id));
|
||||
} else {
|
||||
setError('Failed to revoke token');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to revoke token');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (createdToken) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setError('Failed to copy to clipboard. Please select and copy the token manually.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dismissCreatedToken = () => setCreatedToken(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setShowCreateForm(false);
|
||||
setNewTokenName('');
|
||||
setNewTokenExpiry('never');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null): string => {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
tokens,
|
||||
loading,
|
||||
creating,
|
||||
error,
|
||||
newTokenName,
|
||||
setNewTokenName,
|
||||
newTokenExpiry,
|
||||
setNewTokenExpiry,
|
||||
showCreateForm,
|
||||
setShowCreateForm,
|
||||
createdToken,
|
||||
copied,
|
||||
deletingId,
|
||||
confirmRevokeId,
|
||||
setConfirmRevokeId,
|
||||
fetchTokens,
|
||||
handleCreate,
|
||||
handleDeleteConfirmed,
|
||||
handleCopy,
|
||||
dismissCreatedToken,
|
||||
resetForm,
|
||||
formatDate,
|
||||
};
|
||||
}
|
||||
@@ -35,11 +35,12 @@ export interface Audiobook {
|
||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||
}
|
||||
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||
const hideParam = hideAvailable ? '&hideAvailable=true' : '';
|
||||
const endpoint =
|
||||
type === 'popular'
|
||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}`
|
||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}`;
|
||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}`
|
||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: 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;
|
||||
@@ -27,101 +19,29 @@ export interface GoodreadsShelf {
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetchWithAuth(url).then((res) => res.json());
|
||||
const { useList, useAdd, useDelete, useUpdate } =
|
||||
createShelfHooks<GoodreadsShelf>('/api/user/goodreads-shelves');
|
||||
|
||||
export function useGoodreadsShelves() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
endpoint,
|
||||
fetcher,
|
||||
{ refreshInterval: 30000 }
|
||||
);
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as GoodreadsShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
export const useGoodreadsShelves = useList;
|
||||
|
||||
export function useAddGoodreadsShelf() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||
|
||||
const addShelf = async (rssUrl: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rssUrl }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to add shelf');
|
||||
}
|
||||
|
||||
// Revalidate shelves list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
|
||||
|
||||
return data.shelf as GoodreadsShelf;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return addGeneric({ rssUrl });
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
}
|
||||
|
||||
export function useDeleteGoodreadsShelf() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export const useDeleteGoodreadsShelf = useDelete;
|
||||
|
||||
const deleteShelf = async (shelfId: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
export function useUpdateGoodreadsShelf() {
|
||||
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to remove shelf');
|
||||
}
|
||||
|
||||
// Revalidate shelves list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
const updateShelf = async (shelfId: string, rssUrl: string) => {
|
||||
return updateGeneric(shelfId, { rssUrl });
|
||||
};
|
||||
|
||||
return { deleteShelf, isLoading, error };
|
||||
return { updateShelf, isLoading, error };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Component: Hardcover Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { createShelfHooks, ShelfBook } from './createShelfHooks';
|
||||
|
||||
export type { ShelfBook };
|
||||
|
||||
export interface HardcoverShelf {
|
||||
id: string;
|
||||
name: string;
|
||||
listId: string;
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const { useList, useAdd, useDelete, useUpdate } =
|
||||
createShelfHooks<HardcoverShelf>('/api/user/hardcover-shelves');
|
||||
|
||||
export const useHardcoverShelves = useList;
|
||||
|
||||
export function useAddHardcoverShelf() {
|
||||
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||
|
||||
const addShelf = async (apiToken: string, listId: string) => {
|
||||
return addGeneric({ apiToken, listId });
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
}
|
||||
|
||||
export const useDeleteHardcoverShelf = useDelete;
|
||||
|
||||
export function useUpdateHardcoverShelf() {
|
||||
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
||||
|
||||
const updateShelf = async (
|
||||
shelfId: string,
|
||||
updates: { listId?: string; apiToken?: string },
|
||||
) => {
|
||||
return updateGeneric(shelfId, updates);
|
||||
};
|
||||
|
||||
return { updateShelf, isLoading, error };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Component: Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ShelfBook } from './useGoodreadsShelves';
|
||||
|
||||
export interface GenericShelf {
|
||||
id: string;
|
||||
type: 'goodreads' | 'hardcover';
|
||||
name: string;
|
||||
sourceId: string; // Either rssUrl or listId
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useShelves() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/shelves' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as GenericShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Component: Watched Authors Hook
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
export interface WatchedAuthorItem {
|
||||
id: string;
|
||||
authorAsin: string;
|
||||
authorName: string;
|
||||
coverArtUrl: string | null;
|
||||
lastCheckedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useWatchedAuthors() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/watched-authors' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
endpoint,
|
||||
fetcher,
|
||||
{ refreshInterval: 60000 }
|
||||
);
|
||||
|
||||
return {
|
||||
authors: (data?.authors || []) as WatchedAuthorItem[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddWatchedAuthor() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addAuthor = async (authorAsin: string, authorName: string, coverArtUrl?: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/watched-authors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ authorAsin, authorName, coverArtUrl }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to watch author');
|
||||
}
|
||||
|
||||
// Revalidate watched authors list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-authors'));
|
||||
|
||||
return data.author as WatchedAuthorItem;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { addAuthor, isLoading, error };
|
||||
}
|
||||
|
||||
export function useDeleteWatchedAuthor() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deleteAuthor = async (id: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/user/watched-authors/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to unwatch author');
|
||||
}
|
||||
|
||||
// Revalidate watched authors list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-authors'));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteAuthor, isLoading, error };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Component: Watched Series Hook
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
export interface WatchedSeriesItem {
|
||||
id: string;
|
||||
seriesAsin: string;
|
||||
seriesTitle: string;
|
||||
coverArtUrl: string | null;
|
||||
lastCheckedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useWatchedSeries() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/watched-series' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
endpoint,
|
||||
fetcher,
|
||||
{ refreshInterval: 60000 }
|
||||
);
|
||||
|
||||
return {
|
||||
series: (data?.series || []) as WatchedSeriesItem[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddWatchedSeries() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addSeries = async (seriesAsin: string, seriesTitle: string, coverArtUrl?: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/watched-series', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seriesAsin, seriesTitle, coverArtUrl }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to watch series');
|
||||
}
|
||||
|
||||
// Revalidate watched series list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-series'));
|
||||
|
||||
return data.series as WatchedSeriesItem;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { addSeries, isLoading, error };
|
||||
}
|
||||
|
||||
export function useDeleteWatchedSeries() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deleteSeries = async (id: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/user/watched-series/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to unwatch series');
|
||||
}
|
||||
|
||||
// Revalidate watched series list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-series'));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteSeries, isLoading, error };
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
getLanguageForRegion,
|
||||
buildContainsSelector,
|
||||
stripPrefixes,
|
||||
type LanguageConfig,
|
||||
} from '../constants/language-config';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { parseRuntime } from '../utils/parse-runtime';
|
||||
import { randomDelay } from '../utils/scrape-resilience';
|
||||
|
||||
const logger = RMABLogger.create('Audible.Series');
|
||||
@@ -311,7 +313,7 @@ export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<
|
||||
undefined;
|
||||
|
||||
// Parse all books from the series page
|
||||
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes);
|
||||
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes, langConfig);
|
||||
|
||||
// Use actual book count if we got more from scraping
|
||||
const bookCount = Math.max(summary.bookCount, books.length);
|
||||
@@ -403,7 +405,8 @@ function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCoun
|
||||
function parseSeriesBooks(
|
||||
$: cheerio.CheerioAPI,
|
||||
authorPrefixes: string[],
|
||||
narratorPrefixes: string[]
|
||||
narratorPrefixes: string[],
|
||||
langConfig: LanguageConfig
|
||||
): AudibleAudiobook[] {
|
||||
const books: AudibleAudiobook[] = [];
|
||||
const seenAsins = new Set<string>();
|
||||
@@ -453,6 +456,11 @@ function parseSeriesBooks(
|
||||
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
||||
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
|
||||
|
||||
// Duration
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||
const durationMinutes = parseRuntime(runtimeText, langConfig);
|
||||
|
||||
books.push({
|
||||
asin: bookAsin,
|
||||
title,
|
||||
@@ -461,6 +469,7 @@ function parseSeriesBooks(
|
||||
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
||||
coverArtUrl,
|
||||
rating,
|
||||
durationMinutes,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
AdaptivePacer,
|
||||
FetchResultMeta,
|
||||
} from '../utils/scrape-resilience';
|
||||
import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime';
|
||||
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('Audible');
|
||||
@@ -1134,33 +1135,11 @@ export class AudibleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse runtime text to minutes using language-specific patterns
|
||||
* Parse runtime text to minutes using language-specific patterns.
|
||||
* Delegates to shared utility in src/lib/utils/parse-runtime.ts.
|
||||
*/
|
||||
private parseRuntime(runtimeText: string): number | undefined {
|
||||
if (!runtimeText) return undefined;
|
||||
|
||||
const langConfig = this.getLangConfig();
|
||||
let totalMinutes = 0;
|
||||
|
||||
// Try each hour pattern until one matches
|
||||
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
|
||||
const match = runtimeText.match(pattern);
|
||||
if (match) {
|
||||
totalMinutes += parseInt(match[1]) * 60;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each minute pattern until one matches
|
||||
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
|
||||
const match = runtimeText.match(pattern);
|
||||
if (match) {
|
||||
totalMinutes += parseInt(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return totalMinutes > 0 ? totalMinutes : undefined;
|
||||
return parseRuntimeUtil(runtimeText, this.getLangConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+107
-3
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
|
||||
|
||||
const logger = RMABLogger.create('Auth');
|
||||
|
||||
@@ -32,9 +34,70 @@ function extractToken(request: NextRequest): string | null {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate via static API token (rmab_ prefix).
|
||||
* Returns a synthetic TokenPayload if valid, null otherwise.
|
||||
* Updates lastUsedAt asynchronously.
|
||||
*/
|
||||
async function authenticateApiToken(token: string): Promise<TokenPayload | null> {
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: {
|
||||
tokenUser: {
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken) return null;
|
||||
|
||||
// Check expiration
|
||||
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
||||
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject tokens for soft-deleted users
|
||||
const user = apiToken.tokenUser;
|
||||
if (!user || user.deletedAt) {
|
||||
logger.warn('API token used by deleted or missing user', {
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
userId: user?.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update lastUsedAt (fire-and-forget)
|
||||
prisma.apiToken.update({
|
||||
where: { id: apiToken.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
}).catch((err) => {
|
||||
logger.debug('Failed to update API token lastUsedAt', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
tokenId: apiToken.id,
|
||||
});
|
||||
});
|
||||
|
||||
// Use the token's target user (userId), not the creator (createdById)
|
||||
return {
|
||||
sub: user.id,
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
role: apiToken.role,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require authentication
|
||||
* Verifies JWT token and adds user to request
|
||||
* Verifies JWT token or static API token and adds user to request
|
||||
*/
|
||||
export async function requireAuth(
|
||||
request: NextRequest,
|
||||
@@ -53,6 +116,43 @@ export async function requireAuth(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a static API token
|
||||
if (token.startsWith(API_TOKEN_PREFIX)) {
|
||||
const apiUser = await authenticateApiToken(token);
|
||||
if (!apiUser) {
|
||||
logger.error('API token authentication failed');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired API token',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce endpoint allowlist for API token auth
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const method = request.method;
|
||||
if (!isEndpointAllowed(method, pathname)) {
|
||||
logger.warn('API token used on restricted endpoint', {
|
||||
method,
|
||||
path: pathname,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden',
|
||||
message: 'This endpoint is not available via API token authentication',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = { ...apiUser, id: apiUser.sub };
|
||||
return handler(authenticatedRequest);
|
||||
}
|
||||
|
||||
// Fall back to JWT verification
|
||||
const payload = verifyAccessToken(token);
|
||||
|
||||
if (!payload) {
|
||||
@@ -69,9 +169,13 @@ export async function requireAuth(
|
||||
// Verify user still exists in database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || user.deletedAt) {
|
||||
logger.error('User not found in database', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -86,7 +190,7 @@ export async function requireAuth(
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = {
|
||||
...payload,
|
||||
id: user.id,
|
||||
id: payload.sub,
|
||||
};
|
||||
|
||||
return handler(authenticatedRequest);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Component: Check Watched Lists Processor
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*
|
||||
* Dedicated processor for checking watched series and watched authors
|
||||
* for new releases and auto-creating requests.
|
||||
* Supports targeted processing of a single series/author for immediate sync.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface CheckWatchedListsPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process watched items for this user */
|
||||
userId?: string;
|
||||
/** If set, only process this specific series */
|
||||
seriesAsin?: string;
|
||||
/** If set, only process this specific author */
|
||||
authorAsin?: string;
|
||||
}
|
||||
|
||||
export async function processCheckWatchedLists(payload: CheckWatchedListsPayload): Promise<any> {
|
||||
const { jobId, userId, seriesAsin, authorAsin } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'CheckWatchedLists');
|
||||
|
||||
const isTargeted = !!(userId && (seriesAsin || authorAsin));
|
||||
logger.info(isTargeted
|
||||
? `Starting targeted watched lists check (user: ${userId}, series: ${seriesAsin || 'n/a'}, author: ${authorAsin || 'n/a'})...`
|
||||
: 'Starting watched lists check...'
|
||||
);
|
||||
|
||||
const { processWatchedLists } = await import('../services/watched-lists.service');
|
||||
const stats = await processWatchedLists(logger, { userId, seriesAsin, authorAsin });
|
||||
|
||||
logger.info('Watched lists check complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: isTargeted ? 'Targeted watched item checked' : 'Watched lists checked',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import { generateFilesHash } from '../utils/files-hash';
|
||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||
import { getAudibleService } from '../integrations/audible.service';
|
||||
|
||||
/**
|
||||
* Process organize files job
|
||||
@@ -118,7 +119,62 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`)
|
||||
// Enrich missing series data from Audnexus (safety net for records created without series)
|
||||
let series = audiobook.series || undefined;
|
||||
let seriesPart = audiobook.seriesPart || undefined;
|
||||
|
||||
if (audiobook.audibleAsin && !series) {
|
||||
try {
|
||||
logger.info(`Missing series data, fetching from Audnexus for ASIN: ${audiobook.audibleAsin}`);
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
|
||||
|
||||
if (audnexusData) {
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (audnexusData.series) {
|
||||
series = audnexusData.series;
|
||||
updates.series = series;
|
||||
logger.info(`Got series "${series}" from Audnexus`);
|
||||
}
|
||||
if (audnexusData.seriesPart) {
|
||||
seriesPart = audnexusData.seriesPart;
|
||||
updates.seriesPart = seriesPart;
|
||||
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
|
||||
}
|
||||
if (audnexusData.seriesAsin) {
|
||||
updates.seriesAsin = audnexusData.seriesAsin;
|
||||
}
|
||||
// Also backfill year/narrator if still missing
|
||||
if (!year && audnexusData.releaseDate) {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
updates.year = year;
|
||||
logger.info(`Got year ${year} from Audnexus`);
|
||||
}
|
||||
}
|
||||
if (!narrator && audnexusData.narrator) {
|
||||
narrator = audnexusData.narrator;
|
||||
updates.narrator = narrator;
|
||||
logger.info(`Got narrator "${narrator}" from Audnexus`);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Updated audiobook record with Audnexus metadata`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal: missing series won't block organization, just degrades path quality
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
|
||||
|
||||
// Get file organizer (reads media_dir from database config)
|
||||
const organizer = await getFileOrganizer();
|
||||
@@ -151,8 +207,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
coverArtUrl: audiobook.coverArtUrl || undefined,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
year,
|
||||
series: audiobook.series || undefined,
|
||||
seriesPart: audiobook.seriesPart || undefined,
|
||||
series,
|
||||
seriesPart,
|
||||
},
|
||||
template,
|
||||
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
|
||||
@@ -545,6 +601,56 @@ async function processEbookOrganization(
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich missing series data from Audnexus (safety net for records created without series)
|
||||
if (book.audibleAsin && !series) {
|
||||
try {
|
||||
logger.info(`Missing series data for ebook, fetching from Audnexus for ASIN: ${book.audibleAsin}`);
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(book.audibleAsin);
|
||||
|
||||
if (audnexusData) {
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (audnexusData.series) {
|
||||
series = audnexusData.series;
|
||||
updates.series = series;
|
||||
logger.info(`Got series "${series}" from Audnexus`);
|
||||
}
|
||||
if (audnexusData.seriesPart) {
|
||||
seriesPart = audnexusData.seriesPart;
|
||||
updates.seriesPart = seriesPart;
|
||||
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
|
||||
}
|
||||
if (audnexusData.seriesAsin) {
|
||||
updates.seriesAsin = audnexusData.seriesAsin;
|
||||
}
|
||||
if (!year && audnexusData.releaseDate) {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
updates.year = year;
|
||||
logger.info(`Got year ${year} from Audnexus`);
|
||||
}
|
||||
}
|
||||
if (!narrator && audnexusData.narrator) {
|
||||
narrator = audnexusData.narrator;
|
||||
updates.narrator = narrator;
|
||||
logger.info(`Got narrator "${narrator}" from Audnexus`);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Updated book record with Audnexus metadata`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${book.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
|
||||
|
||||
// Check if this is an indexer download (needs to keep source for seeding)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Component: Sync Goodreads Shelves Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Dedicated processor for syncing Goodreads shelf RSS feeds.
|
||||
* Resolves books to Audible ASINs and creates requests.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SyncGoodreadsShelvesPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process this specific shelf (used for immediate sync on add) */
|
||||
shelfId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise<any> {
|
||||
const { jobId, shelfId, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves');
|
||||
|
||||
logger.info(shelfId
|
||||
? `Starting immediate Goodreads sync for shelf ${shelfId}...`
|
||||
: 'Starting scheduled Goodreads shelves sync...'
|
||||
);
|
||||
|
||||
const { processGoodreadsShelves } = await import('../services/goodreads-sync.service');
|
||||
const stats = await processGoodreadsShelves(logger, {
|
||||
shelfId,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
logger.info('Goodreads sync complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Component: Sync Shelves Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Dedicated processor for syncing all reading shelves (Goodreads, Hardcover).
|
||||
* Resolves books to Audible ASINs and creates requests.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SyncShelvesPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process this specific shelf (used for immediate sync on add) */
|
||||
shelfId?: string;
|
||||
/** The type of shelf, if shelfId is specified */
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export async function processSyncShelves(
|
||||
payload: SyncShelvesPayload,
|
||||
): Promise<any> {
|
||||
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
|
||||
|
||||
const stats = {
|
||||
shelvesProcessed: 0,
|
||||
booksFound: 0,
|
||||
lookupsPerformed: 0,
|
||||
requestsCreated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
logger.info(
|
||||
shelfId
|
||||
? `Starting immediate ${shelfType} sync for list ${shelfId}...`
|
||||
: 'Starting scheduled shelves sync...',
|
||||
);
|
||||
|
||||
const shouldSyncGoodreads = !shelfType || shelfType === 'goodreads';
|
||||
const shouldSyncHardcover = !shelfType || shelfType === 'hardcover';
|
||||
|
||||
if (shouldSyncGoodreads) {
|
||||
try {
|
||||
const { processGoodreadsShelves } =
|
||||
await import('../services/goodreads-sync.service');
|
||||
const grStats = await processGoodreadsShelves(logger, {
|
||||
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
stats.shelvesProcessed += grStats.shelvesProcessed;
|
||||
stats.booksFound += grStats.booksFound;
|
||||
stats.lookupsPerformed += grStats.lookupsPerformed;
|
||||
stats.requestsCreated += grStats.requestsCreated;
|
||||
stats.errors += grStats.errors;
|
||||
} catch (error) {
|
||||
logger.error('Goodreads sync failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSyncHardcover) {
|
||||
try {
|
||||
const { processHardcoverShelves } =
|
||||
await import('../services/hardcover-sync.service');
|
||||
const hcStats = await processHardcoverShelves(logger, {
|
||||
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
stats.shelvesProcessed += hcStats.shelvesProcessed;
|
||||
stats.booksFound += hcStats.booksFound;
|
||||
stats.lookupsPerformed += hcStats.lookupsPerformed;
|
||||
stats.requestsCreated += hcStats.requestsCreated;
|
||||
stats.errors += hcStats.errors;
|
||||
} catch (error) {
|
||||
logger.error('Hardcover sync failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Shelves sync complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: shelfId ? `${shelfType} list synced` : 'Reading shelves synced',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
return { success: false, error: 'Username and password required' };
|
||||
}
|
||||
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
|
||||
// Find user (exclude soft-deleted users)
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
plexUsername: username,
|
||||
plexUsername: normalizedUsername,
|
||||
authProvider: 'local',
|
||||
deletedAt: null, // Exclude soft-deleted users
|
||||
},
|
||||
@@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
async register(params: RegisterParams): Promise<AuthResult> {
|
||||
try {
|
||||
const { username, password } = params;
|
||||
const normalizedUsername = username?.trim().toLowerCase();
|
||||
|
||||
// Validate
|
||||
if (!username || username.length < 3) {
|
||||
if (!normalizedUsername || normalizedUsername.length < 3) {
|
||||
return { success: false, error: 'Username must be at least 3 characters' };
|
||||
}
|
||||
|
||||
@@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
// Check username uniqueness (only among non-deleted users)
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
plexUsername: username,
|
||||
plexUsername: normalizedUsername,
|
||||
authProvider: 'local',
|
||||
deletedAt: null, // Allow reuse of usernames from deleted accounts
|
||||
},
|
||||
@@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
plexId: `local-${username}`,
|
||||
plexUsername: username,
|
||||
plexId: `local-${normalizedUsername}`,
|
||||
plexUsername: normalizedUsername,
|
||||
authToken: encryptedHash,
|
||||
authProvider: 'local',
|
||||
role: isFirstUser ? 'admin' : 'user',
|
||||
|
||||
@@ -2,36 +2,29 @@
|
||||
* Component: Goodreads Shelf Sync Service
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*
|
||||
* Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs,
|
||||
* and creates requests via the shared request-creator service.
|
||||
* Fetches Goodreads shelf RSS feeds and delegates book processing
|
||||
* to the shared shelf-sync-core service.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import {
|
||||
ShelfBook,
|
||||
ShelfSyncStats,
|
||||
ShelfSyncOptions,
|
||||
createEmptyStats,
|
||||
resolveMaxLookups,
|
||||
processShelfBooks,
|
||||
} from '@/lib/services/shelf-sync-core.service';
|
||||
|
||||
const logger = RMABLogger.create('GoodreadsSync');
|
||||
|
||||
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
||||
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
||||
|
||||
/** Days before retrying a noMatch book */
|
||||
const NO_MATCH_RETRY_DAYS = 7;
|
||||
|
||||
interface GoodreadsRssBook {
|
||||
bookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Goodreads RSS feed XML into structured book data.
|
||||
*/
|
||||
function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } {
|
||||
function parseGoodreadsRss(xml: string): { shelfName: string; books: ShelfBook[] } {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
@@ -46,65 +39,84 @@ function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRs
|
||||
|
||||
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
|
||||
|
||||
// Normalize items to array
|
||||
let items = channel.item;
|
||||
if (!items) return { shelfName, books: [] };
|
||||
if (!Array.isArray(items)) items = [items];
|
||||
|
||||
const books: GoodreadsRssBook[] = [];
|
||||
const books: ShelfBook[] = [];
|
||||
for (const item of items) {
|
||||
const bookId = item.book_id?.toString();
|
||||
if (!bookId) continue;
|
||||
|
||||
const title = (item.title || '').toString().trim();
|
||||
const authorName = (item.author_name || '').toString().trim();
|
||||
// Goodreads RSS has book_image_url or book_medium_image_url
|
||||
const author = (item.author_name || '').toString().trim();
|
||||
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
|
||||
|
||||
if (title && authorName) {
|
||||
books.push({ bookId, title, author: authorName, coverUrl });
|
||||
if (title && author) {
|
||||
books.push({ bookId, title, author, coverUrl });
|
||||
}
|
||||
}
|
||||
|
||||
return { shelfName, books };
|
||||
}
|
||||
|
||||
/** Max items Goodreads returns per RSS page */
|
||||
const GOODREADS_PAGE_SIZE = 100;
|
||||
|
||||
/** Safety cap to avoid infinite loops */
|
||||
const MAX_PAGES = 50;
|
||||
|
||||
/**
|
||||
* Fetch and validate a Goodreads RSS URL.
|
||||
* Returns the parsed shelf name and books if valid.
|
||||
* Automatically paginates (sort=title, page=1,2,...) when a page returns 100 items.
|
||||
* Deduplicates by bookId across pages.
|
||||
*/
|
||||
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> {
|
||||
const response = await axios.get(rssUrl, { timeout: 15000 });
|
||||
return parseGoodreadsRss(response.data);
|
||||
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: ShelfBook[] }> {
|
||||
const url = new URL(rssUrl);
|
||||
url.searchParams.set('sort', 'title');
|
||||
|
||||
let shelfName = 'Goodreads Shelf';
|
||||
const seenIds = new Set<string>();
|
||||
const allBooks: ShelfBook[] = [];
|
||||
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
url.searchParams.set('page', page.toString());
|
||||
|
||||
const response = await axios.get(url.toString(), { timeout: 15000 });
|
||||
const parsed = parseGoodreadsRss(response.data);
|
||||
|
||||
if (page === 1) {
|
||||
shelfName = parsed.shelfName;
|
||||
}
|
||||
|
||||
for (const book of parsed.books) {
|
||||
if (!seenIds.has(book.bookId)) {
|
||||
seenIds.add(book.bookId);
|
||||
allBooks.push(book);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.books.length < GOODREADS_PAGE_SIZE) break;
|
||||
}
|
||||
|
||||
return { shelfName, books: allBooks };
|
||||
}
|
||||
|
||||
export interface GoodreadsSyncStats {
|
||||
shelvesProcessed: number;
|
||||
booksFound: number;
|
||||
lookupsPerformed: number;
|
||||
requestsCreated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface GoodreadsSyncOptions {
|
||||
/** Process only this shelf ID (for immediate single-shelf sync) */
|
||||
shelfId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
// Re-export types that downstream consumers expect
|
||||
export type { ShelfSyncStats as GoodreadsSyncStats };
|
||||
export type { ShelfSyncOptions as GoodreadsSyncOptions };
|
||||
|
||||
/**
|
||||
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
|
||||
* Called from the dedicated sync_goodreads_shelves processor.
|
||||
* Called from the unified sync_reading_shelves processor.
|
||||
*/
|
||||
export async function processGoodreadsShelves(
|
||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||
options: GoodreadsSyncOptions = {}
|
||||
): Promise<GoodreadsSyncStats> {
|
||||
options: ShelfSyncOptions = {}
|
||||
): Promise<ShelfSyncStats> {
|
||||
const log = jobLogger || logger;
|
||||
const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
|
||||
|
||||
const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
||||
const stats = createEmptyStats();
|
||||
const maxLookups = resolveMaxLookups(options);
|
||||
|
||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
||||
const shelves = await prisma.goodreadsShelf.findMany({
|
||||
@@ -121,7 +133,32 @@ export async function processGoodreadsShelves(
|
||||
|
||||
for (const shelf of shelves) {
|
||||
try {
|
||||
await processShelf(shelf, stats, log, maxLookups);
|
||||
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||
|
||||
let rssData: { shelfName: string; books: ShelfBook[] };
|
||||
try {
|
||||
rssData = await fetchAndValidateRss(shelf.rssUrl);
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
stats.errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"`);
|
||||
|
||||
const bookData = await processShelfBooks(
|
||||
'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
|
||||
);
|
||||
|
||||
await prisma.goodreadsShelf.update({
|
||||
where: { id: shelf.id },
|
||||
data: {
|
||||
lastSyncAt: new Date(),
|
||||
bookCount: rssData.books.length,
|
||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||
},
|
||||
});
|
||||
|
||||
stats.shelvesProcessed++;
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
@@ -132,238 +169,3 @@ export async function processGoodreadsShelves(
|
||||
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
async function processShelf(
|
||||
shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } },
|
||||
stats: GoodreadsSyncStats,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
maxLookups: number
|
||||
) {
|
||||
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||
|
||||
let rssData: { shelfName: string; books: GoodreadsRssBook[] };
|
||||
try {
|
||||
rssData = await fetchAndValidateRss(shelf.rssUrl);
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const books = rssData.books;
|
||||
stats.booksFound += books.length;
|
||||
log.info(`Found ${books.length} books in shelf "${shelf.name}"`);
|
||||
|
||||
let lookupsThisCycle = 0;
|
||||
const unlimitedLookups = maxLookups === 0;
|
||||
|
||||
for (const book of books) {
|
||||
// Look up existing mapping
|
||||
let mapping = await prisma.goodreadsBookMapping.findUnique({
|
||||
where: { goodreadsBookId: book.bookId },
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
// No mapping exists — perform Audible lookup if under cap
|
||||
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) {
|
||||
continue; // Will be resolved in a future cycle
|
||||
}
|
||||
|
||||
mapping = await performAudibleLookup(book, log);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
// If lookup found an ASIN, fall through to create request immediately
|
||||
if (!mapping?.audibleAsin) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping exists with noMatch — check if we should retry
|
||||
if (mapping.noMatch) {
|
||||
if (mapping.lastSearchAt) {
|
||||
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
|
||||
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
|
||||
mapping = await performAudibleLookup(book, log, mapping.id);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
// If retry found an ASIN, fall through to create request
|
||||
if (!mapping?.audibleAsin) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue; // Still no match, skip
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping has ASIN — try to create request
|
||||
if (mapping.audibleAsin) {
|
||||
try {
|
||||
const result = await createRequestForUser(shelf.user.id, {
|
||||
asin: mapping.audibleAsin,
|
||||
title: mapping.title,
|
||||
author: mapping.author,
|
||||
coverArtUrl: mapping.coverUrl || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
stats.requestsCreated++;
|
||||
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
|
||||
}
|
||||
// If not success, it's already available/requested/duplicate — silently skip
|
||||
} catch (error) {
|
||||
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect enriched book data (coverUrl + ASIN) for display
|
||||
const bookIds = books.map(b => b.bookId);
|
||||
const mappings = bookIds.length > 0
|
||||
? await prisma.goodreadsBookMapping.findMany({
|
||||
where: { goodreadsBookId: { in: bookIds } },
|
||||
select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
|
||||
})
|
||||
: [];
|
||||
const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m]));
|
||||
|
||||
// Look up AudibleCache records for high-quality cached cover URLs
|
||||
const matchedAsins = mappings
|
||||
.map(m => m.audibleAsin)
|
||||
.filter((asin): asin is string => !!asin);
|
||||
const cachedCovers = matchedAsins.length > 0
|
||||
? await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: matchedAsins } },
|
||||
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
|
||||
})
|
||||
: [];
|
||||
const coverByAsin = new Map(
|
||||
cachedCovers
|
||||
.filter(c => c.cachedCoverPath || c.coverArtUrl)
|
||||
.map(c => {
|
||||
let coverUrl = c.coverArtUrl || '';
|
||||
if (c.cachedCoverPath) {
|
||||
const filename = c.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
return [c.asin, coverUrl] as const;
|
||||
})
|
||||
);
|
||||
|
||||
const bookData = books
|
||||
.map(b => {
|
||||
const mapping = mappingsByBookId.get(b.bookId);
|
||||
// Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover
|
||||
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
|
||||
if (!coverUrl) return null;
|
||||
return {
|
||||
coverUrl,
|
||||
asin: mapping?.audibleAsin || null,
|
||||
title: mapping?.title || b.title,
|
||||
author: mapping?.author || b.author,
|
||||
};
|
||||
})
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.slice(0, 8);
|
||||
|
||||
// Update shelf metadata
|
||||
await prisma.goodreadsShelf.update({
|
||||
where: { id: shelf.id },
|
||||
data: {
|
||||
lastSyncAt: new Date(),
|
||||
bookCount: books.length,
|
||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function performAudibleLookup(
|
||||
book: GoodreadsRssBook,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
existingMappingId?: string
|
||||
): Promise<any> {
|
||||
const audibleService = getAudibleService();
|
||||
|
||||
try {
|
||||
// Try full Goodreads title first, then fall back to stripped title
|
||||
// (Goodreads titles often include series info like "(Demonica, #2)" that return 0 Audible results)
|
||||
const fullQuery = `${book.title} ${book.author}`;
|
||||
log.info(`Searching Audible for: "${fullQuery}"`);
|
||||
|
||||
let searchResult = await audibleService.search(fullQuery);
|
||||
let firstResult = searchResult.results[0];
|
||||
|
||||
if (!firstResult?.asin) {
|
||||
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
|
||||
if (cleanTitle !== book.title) {
|
||||
const cleanQuery = `${cleanTitle} ${book.author}`;
|
||||
log.info(`No results with full title, retrying without series info: "${cleanQuery}"`);
|
||||
searchResult = await audibleService.search(cleanQuery);
|
||||
firstResult = searchResult.results[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (firstResult?.asin) {
|
||||
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
|
||||
|
||||
// Use clean Audible/Audnexus metadata instead of Goodreads data
|
||||
// (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches)
|
||||
const data = {
|
||||
title: firstResult.title,
|
||||
author: firstResult.author,
|
||||
audibleAsin: firstResult.asin,
|
||||
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
|
||||
noMatch: false,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
// No match found
|
||||
log.info(`No Audible match for "${book.title}" by ${book.author}`);
|
||||
|
||||
const noMatchData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
audibleAsin: null,
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...noMatchData },
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Still create/update mapping so we don't retry every cycle
|
||||
const errorData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...errorData },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Component: Hardcover API Service
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*
|
||||
* GraphQL queries and API communication with the Hardcover platform.
|
||||
* Exports fetchHardcoverList for use by the sync orchestration layer.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('HardcoverAPI');
|
||||
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
|
||||
|
||||
export interface HardcoverApiBook {
|
||||
bookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
/** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */
|
||||
interface HardcoverBookNode {
|
||||
id?: number;
|
||||
title?: string;
|
||||
cached_image?: string | { url?: string };
|
||||
image?: { url?: string };
|
||||
contributions?: Array<{ author?: { name?: string } }>;
|
||||
}
|
||||
|
||||
/** Shape of a list object returned from the Hardcover GraphQL API */
|
||||
interface HardcoverListData {
|
||||
name?: string;
|
||||
list_books?: Array<{ book?: HardcoverBookNode }>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 50;
|
||||
|
||||
/** Extract HardcoverApiBook[] from an array of book-containing items */
|
||||
function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] {
|
||||
const books: HardcoverApiBook[] = [];
|
||||
for (const item of items) {
|
||||
const book = item.book;
|
||||
if (!book || !book.id) continue;
|
||||
|
||||
const authorName =
|
||||
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
||||
const cachedImg = book.cached_image;
|
||||
const coverUrl =
|
||||
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) ||
|
||||
book.image?.url ||
|
||||
undefined;
|
||||
|
||||
books.push({
|
||||
bookId: book.id.toString(),
|
||||
title: book.title || 'Unknown Title',
|
||||
author: authorName,
|
||||
coverUrl,
|
||||
});
|
||||
}
|
||||
return books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Hardcover List using their GraphQL API.
|
||||
* This handles both 'status_id' user_books or 'list_id' list_books queries.
|
||||
* For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id.
|
||||
*/
|
||||
export async function fetchHardcoverList(
|
||||
apiToken: string,
|
||||
listIdStr: string,
|
||||
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
|
||||
// Check if it's a status list
|
||||
const isStatus = listIdStr.startsWith('status-');
|
||||
|
||||
if (isStatus) {
|
||||
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
||||
const query = `
|
||||
query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) {
|
||||
me {
|
||||
user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) {
|
||||
book {
|
||||
id
|
||||
title
|
||||
contributions {
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
cached_image
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Map status numbers to names
|
||||
const statusNames: Record<number, string> = {
|
||||
1: 'Want to Read',
|
||||
2: 'Currently Reading',
|
||||
3: 'Read',
|
||||
4: 'Did Not Finish',
|
||||
};
|
||||
const listName = statusNames[statusId] || `Status ${statusId}`;
|
||||
|
||||
const allBooks: HardcoverApiBook[] = [];
|
||||
let offset = 0;
|
||||
let page = 0;
|
||||
|
||||
// Paginate until fewer results than PAGE_SIZE are returned
|
||||
while (++page <= MAX_PAGES) {
|
||||
const response = await axios.post(
|
||||
HARDCOVER_API_URL,
|
||||
{ query, variables: { statusId, limit: PAGE_SIZE, offset } },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data?.errors) {
|
||||
throw new Error(
|
||||
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const userBooks: Array<{ book?: HardcoverBookNode }> =
|
||||
response.data?.data?.me?.[0]?.user_books || [];
|
||||
const pageBooks = extractBooks(userBooks);
|
||||
allBooks.push(...pageBooks);
|
||||
|
||||
if (userBooks.length < PAGE_SIZE) break;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
|
||||
return { listName, books: allBooks };
|
||||
} else {
|
||||
// Custom list query
|
||||
// - URL with @username → query that user's lists by slug
|
||||
// - Bare slug (no username) → query authenticated user's lists via `me`
|
||||
// - Numeric ID → query globally (IDs are unique)
|
||||
const isIntId = /^\d+$/.test(listIdStr);
|
||||
let extractedSlug = listIdStr;
|
||||
let extractedUsername: string | null = null;
|
||||
|
||||
if (!isIntId) {
|
||||
try {
|
||||
if (listIdStr.includes('hardcover.app')) {
|
||||
const url = new URL(
|
||||
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
|
||||
);
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
// URL format: /@username/lists/slug
|
||||
if (parts.length > 0) {
|
||||
extractedSlug = parts[parts.length - 1];
|
||||
}
|
||||
const userPart = parts.find((p) => p.startsWith('@'));
|
||||
if (userPart) {
|
||||
extractedUsername = userPart.slice(1);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// use extractedSlug as-is
|
||||
}
|
||||
}
|
||||
|
||||
const listBookFields = `
|
||||
name
|
||||
list_books(limit: $limit, offset: $offset, order_by: {id: desc}) {
|
||||
book {
|
||||
id title cached_image image { url }
|
||||
contributions { author { name } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Numeric ID: globally unique, query the lists table directly
|
||||
const queryById = `
|
||||
query GetListBooks($listId: Int!, $limit: Int!, $offset: Int!) {
|
||||
lists(where: {id: {_eq: $listId}}, limit: 1) {
|
||||
${listBookFields}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Slug with username: query through the users table to scope to that user
|
||||
const queryByUserSlug = `
|
||||
query GetUserListBySlug($username: citext!, $slug: String!, $limit: Int!, $offset: Int!) {
|
||||
users(where: {username: {_eq: $username}}, limit: 1) {
|
||||
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
||||
${listBookFields}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Bare slug (no username): scope to the authenticated user via `me`
|
||||
const queryByMySlug = `
|
||||
query GetMyListBySlug($slug: String!, $limit: Int!, $offset: Int!) {
|
||||
me {
|
||||
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
||||
${listBookFields}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let activeQuery: string;
|
||||
let baseVariables: Record<string, unknown>;
|
||||
|
||||
if (isIntId) {
|
||||
activeQuery = queryById;
|
||||
baseVariables = { listId: parseInt(listIdStr, 10) };
|
||||
} else if (extractedUsername) {
|
||||
activeQuery = queryByUserSlug;
|
||||
baseVariables = { username: extractedUsername, slug: extractedSlug };
|
||||
} else {
|
||||
activeQuery = queryByMySlug;
|
||||
baseVariables = { slug: extractedSlug };
|
||||
}
|
||||
|
||||
// First request to discover list metadata and first page of books
|
||||
const firstResponse = await axios.post(
|
||||
HARDCOVER_API_URL,
|
||||
{
|
||||
query: activeQuery,
|
||||
variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (firstResponse.data?.errors) {
|
||||
throw new Error(
|
||||
`Hardcover API Error: ${firstResponse.data.errors[0]?.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract lists array from the response based on which query was used
|
||||
let listsData: HardcoverListData[];
|
||||
if (isIntId) {
|
||||
listsData = firstResponse.data?.data?.lists || [];
|
||||
} else if (extractedUsername) {
|
||||
const users = firstResponse.data?.data?.users || [];
|
||||
listsData = users[0]?.lists || [];
|
||||
} else {
|
||||
listsData = firstResponse.data?.data?.me?.[0]?.lists || [];
|
||||
}
|
||||
|
||||
if (listsData.length === 0) {
|
||||
let identifier: string;
|
||||
if (isIntId) {
|
||||
identifier = `ID "${listIdStr}"`;
|
||||
} else if (extractedUsername) {
|
||||
identifier = `slug "${extractedSlug}" for user @${extractedUsername}`;
|
||||
} else {
|
||||
identifier = `slug "${extractedSlug}" in your Hardcover account`;
|
||||
}
|
||||
throw new Error(`Could not find a list with ${identifier}`);
|
||||
}
|
||||
|
||||
const listName = listsData[0].name || 'Hardcover List';
|
||||
const firstPageItems = listsData[0].list_books || [];
|
||||
const allBooks = extractBooks(firstPageItems);
|
||||
|
||||
// Paginate if first page was full
|
||||
if (firstPageItems.length >= PAGE_SIZE) {
|
||||
let offset = PAGE_SIZE;
|
||||
let page = 1; // first page already fetched
|
||||
|
||||
while (++page <= MAX_PAGES) {
|
||||
const pageResponse = await axios.post(
|
||||
HARDCOVER_API_URL,
|
||||
{
|
||||
query: activeQuery,
|
||||
variables: { ...baseVariables, limit: PAGE_SIZE, offset },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (pageResponse.data?.errors) {
|
||||
logger.warn('Hardcover pagination interrupted by API error', {
|
||||
errors: pageResponse.data.errors,
|
||||
offset,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
let pageListsData: HardcoverListData[];
|
||||
if (isIntId) {
|
||||
pageListsData = pageResponse.data?.data?.lists || [];
|
||||
} else if (extractedUsername) {
|
||||
const users = pageResponse.data?.data?.users || [];
|
||||
pageListsData = users[0]?.lists || [];
|
||||
} else {
|
||||
pageListsData = pageResponse.data?.data?.me?.[0]?.lists || [];
|
||||
}
|
||||
|
||||
const pageItems = pageListsData[0]?.list_books || [];
|
||||
allBooks.push(...extractBooks(pageItems));
|
||||
|
||||
if (pageItems.length < PAGE_SIZE) break;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
return { listName, books: allBooks };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Component: Hardcover Shelf Sync Service
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*
|
||||
* Fetches Hardcover lists via GraphQL API and delegates book processing
|
||||
* to the shared shelf-sync-core service.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||
import {
|
||||
ShelfSyncStats,
|
||||
ShelfSyncOptions,
|
||||
createEmptyStats,
|
||||
resolveMaxLookups,
|
||||
processShelfBooks,
|
||||
} from '@/lib/services/shelf-sync-core.service';
|
||||
|
||||
export { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
|
||||
export type { HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||
|
||||
const logger = RMABLogger.create('HardcoverSync');
|
||||
|
||||
// Re-export types that downstream consumers expect
|
||||
export type { ShelfSyncStats as HardcoverSyncStats };
|
||||
export type { ShelfSyncOptions as HardcoverSyncOptions };
|
||||
|
||||
/**
|
||||
* Process Hardcover shelves: fetch lists via GraphQL, resolve ASINs, create requests.
|
||||
* Called from the unified sync_reading_shelves processor.
|
||||
*/
|
||||
export async function processHardcoverShelves(
|
||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||
options: ShelfSyncOptions = {},
|
||||
): Promise<ShelfSyncStats> {
|
||||
const log = jobLogger || logger;
|
||||
const stats = createEmptyStats();
|
||||
const maxLookups = resolveMaxLookups(options);
|
||||
|
||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
||||
const shelves = await prisma.hardcoverShelf.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
if (shelves.length === 0) {
|
||||
log.info(
|
||||
options.shelfId
|
||||
? 'Hardcover list not found'
|
||||
: 'No Hardcover lists configured, skipping',
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Processing ${shelves.length} Hardcover list(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/list)` : ' (unlimited lookups)'}`,
|
||||
);
|
||||
|
||||
for (const shelf of shelves) {
|
||||
try {
|
||||
log.info(`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
let decryptedToken = shelf.apiToken;
|
||||
try {
|
||||
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
|
||||
decryptedToken = encryptionService.decrypt(shelf.apiToken);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(`Failed to decrypt API token for user ${shelf.user.plexUsername}`);
|
||||
stats.errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let fetchedData: { listName: string; books: HardcoverApiBook[] };
|
||||
try {
|
||||
fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
stats.errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)`);
|
||||
|
||||
const bookData = await processShelfBooks(
|
||||
'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
|
||||
);
|
||||
|
||||
const finalListName =
|
||||
fetchedData.listName !== 'Hardcover List'
|
||||
? fetchedData.listName
|
||||
: shelf.name;
|
||||
|
||||
await prisma.hardcoverShelf.update({
|
||||
where: { id: shelf.id },
|
||||
data: {
|
||||
name: finalListName,
|
||||
lastSyncAt: new Date(),
|
||||
bookCount: fetchedData.books.length,
|
||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||
},
|
||||
});
|
||||
|
||||
stats.shelvesProcessed++;
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
log.error(
|
||||
`Failed to process list "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Hardcover sync complete: ${stats.shelvesProcessed} lists, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`,
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
@@ -26,7 +26,8 @@ export type JobType =
|
||||
| 'retry_failed_imports'
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds'
|
||||
| 'sync_goodreads_shelves'
|
||||
| 'sync_reading_shelves'
|
||||
| 'check_watched_lists'
|
||||
| 'send_notification'
|
||||
// Ebook-specific job types
|
||||
| 'search_ebook'
|
||||
@@ -107,12 +108,23 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
|
||||
export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
||||
export interface SyncShelvesPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
shelfId?: string;
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export interface CheckWatchedListsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process watched items for this user */
|
||||
userId?: string;
|
||||
/** If set, only process this specific series */
|
||||
seriesAsin?: string;
|
||||
/** If set, only process this specific author */
|
||||
authorAsin?: string;
|
||||
}
|
||||
|
||||
// Ebook-specific payload interfaces
|
||||
export interface SearchEbookPayload extends JobPayload {
|
||||
requestId: string;
|
||||
@@ -378,10 +390,16 @@ export class JobQueueService {
|
||||
return await processCleanupSeededTorrents(payloadWithJobId);
|
||||
});
|
||||
|
||||
this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
|
||||
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves');
|
||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
||||
this.queue.process('sync_reading_shelves', 1, async (job: BullJob<SyncShelvesPayload>) => {
|
||||
const { processSyncShelves } = await import('../processors/sync-shelves.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_reading_shelves');
|
||||
return await processSyncShelves(payloadWithJobId);
|
||||
});
|
||||
|
||||
this.queue.process('check_watched_lists', 1, async (job: BullJob<CheckWatchedListsPayload>) => {
|
||||
const { processCheckWatchedLists } = await import('../processors/check-watched-lists.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'check_watched_lists');
|
||||
return await processCheckWatchedLists(payloadWithJobId);
|
||||
});
|
||||
|
||||
// Send notification processor
|
||||
@@ -750,22 +768,56 @@ export class JobQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sync Goodreads shelves job
|
||||
* Add sync reading shelves job
|
||||
*/
|
||||
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> {
|
||||
async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise<string> {
|
||||
return await this.addJob(
|
||||
'sync_goodreads_shelves',
|
||||
'sync_reading_shelves',
|
||||
{
|
||||
scheduledJobId,
|
||||
shelfId,
|
||||
shelfType,
|
||||
maxLookupsPerShelf,
|
||||
} as SyncGoodreadsShelvesPayload,
|
||||
} as SyncShelvesPayload,
|
||||
{
|
||||
priority: 7,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add check watched lists job (watched series + watched authors)
|
||||
*/
|
||||
async addCheckWatchedListsJob(scheduledJobId?: string): Promise<string> {
|
||||
return await this.addJob(
|
||||
'check_watched_lists',
|
||||
{
|
||||
scheduledJobId,
|
||||
} as CheckWatchedListsPayload,
|
||||
{
|
||||
priority: 7,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a targeted check for a specific watched series or author for a specific user.
|
||||
* Used for immediate processing when a user adds a new watch.
|
||||
*/
|
||||
async addCheckWatchedItemJob(userId: string, seriesAsin?: string, authorAsin?: string): Promise<string> {
|
||||
return await this.addJob(
|
||||
'check_watched_lists',
|
||||
{
|
||||
userId,
|
||||
seriesAsin,
|
||||
authorAsin,
|
||||
} as CheckWatchedListsPayload,
|
||||
{
|
||||
priority: 8, // Higher than scheduled (7) since user-initiated
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK-SPECIFIC JOB METHODS
|
||||
// =========================================================================
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { seedAsin } from '@/lib/services/works.service';
|
||||
|
||||
const logger = RMABLogger.create('RequestCreator');
|
||||
|
||||
@@ -147,6 +148,15 @@ export async function createRequestForUser(
|
||||
}
|
||||
}
|
||||
|
||||
// Seed works table for cross-ASIN matching (Layer 2: request-time seeding)
|
||||
seedAsin(
|
||||
audiobook.asin,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
audiobookRecord.narrator || undefined,
|
||||
undefined // duration not available at request time
|
||||
).catch(() => {});
|
||||
|
||||
// Check if user already has an active request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Scheduler');
|
||||
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves' | 'check_watched_lists';
|
||||
|
||||
export interface ScheduledJob {
|
||||
id: string;
|
||||
@@ -59,6 +59,9 @@ export class SchedulerService {
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up deprecated scheduled jobs
|
||||
await this.cleanupDeprecatedJobs();
|
||||
|
||||
// Create default jobs if they don't exist
|
||||
await this.ensureDefaultJobs();
|
||||
|
||||
@@ -127,12 +130,19 @@ export class SchedulerService {
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
name: 'Sync Goodreads Shelves',
|
||||
type: 'sync_goodreads_shelves' as ScheduledJobType,
|
||||
name: 'Sync Reading Shelves',
|
||||
type: 'sync_reading_shelves' as ScheduledJobType,
|
||||
schedule: '0 */6 * * *', // Every 6 hours
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
name: 'Check Watched Lists',
|
||||
type: 'check_watched_lists' as ScheduledJobType,
|
||||
schedule: '0 0 * * *', // Daily at midnight (every 24 hours)
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
let created = 0;
|
||||
@@ -167,6 +177,31 @@ export class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any old jobs that are no longer supported
|
||||
*/
|
||||
private async cleanupDeprecatedJobs(): Promise<void> {
|
||||
try {
|
||||
const deprecatedTypes = ['sync_goodreads_shelves'];
|
||||
|
||||
const obsoleteJobs = await prisma.scheduledJob.findMany({
|
||||
where: { type: { in: deprecatedTypes } },
|
||||
});
|
||||
|
||||
for (const job of obsoleteJobs) {
|
||||
if (job.enabled) {
|
||||
await this.unscheduleJob(job);
|
||||
}
|
||||
await prisma.scheduledJob.delete({ where: { id: job.id } });
|
||||
logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup deprecated scheduled jobs', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule all enabled jobs
|
||||
*/
|
||||
@@ -350,8 +385,11 @@ export class SchedulerService {
|
||||
case 'monitor_rss_feeds':
|
||||
bullJobId = await this.triggerMonitorRssFeeds(job);
|
||||
break;
|
||||
case 'sync_goodreads_shelves':
|
||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
||||
case 'sync_reading_shelves':
|
||||
bullJobId = await this.triggerSyncShelves(job);
|
||||
break;
|
||||
case 'check_watched_lists':
|
||||
bullJobId = await this.triggerCheckWatchedLists(job);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${job.type}`);
|
||||
@@ -622,10 +660,17 @@ export class SchedulerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Goodreads shelves sync
|
||||
* Trigger Reading shelves sync
|
||||
*/
|
||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
||||
private async triggerSyncShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncShelvesJob(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger watched lists check (watched series + watched authors)
|
||||
*/
|
||||
private async triggerCheckWatchedLists(job: any): Promise<string> {
|
||||
return await this.jobQueue.addCheckWatchedListsJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Component: Shelf Sync Core Service
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*
|
||||
* Shared logic for all shelf providers: Audible lookup, noMatch retry,
|
||||
* request creation, cover enrichment, and shelf metadata updates.
|
||||
* Provider-specific services (Goodreads, Hardcover) call into this core.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { BookMapping } from '@/generated/prisma';
|
||||
|
||||
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
||||
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
||||
|
||||
/** Days before retrying a noMatch book */
|
||||
const NO_MATCH_RETRY_DAYS = 7;
|
||||
|
||||
/** Provider-agnostic book from any shelf source */
|
||||
export interface ShelfBook {
|
||||
bookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
/** Sync stats shared across all providers */
|
||||
export interface ShelfSyncStats {
|
||||
shelvesProcessed: number;
|
||||
booksFound: number;
|
||||
lookupsPerformed: number;
|
||||
requestsCreated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** Common sync options */
|
||||
export interface ShelfSyncOptions {
|
||||
shelfId?: string;
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
type LoggerType = ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>;
|
||||
|
||||
export function createEmptyStats(): ShelfSyncStats {
|
||||
return { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
|
||||
}
|
||||
|
||||
export function mergeStats(target: ShelfSyncStats, source: ShelfSyncStats): void {
|
||||
target.shelvesProcessed += source.shelvesProcessed;
|
||||
target.booksFound += source.booksFound;
|
||||
target.lookupsPerformed += source.lookupsPerformed;
|
||||
target.requestsCreated += source.requestsCreated;
|
||||
target.errors += source.errors;
|
||||
}
|
||||
|
||||
export function resolveMaxLookups(options: ShelfSyncOptions): number {
|
||||
return options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a list of books from any provider: resolve to ASINs, create requests,
|
||||
* enrich covers, and return book data for shelf metadata.
|
||||
*/
|
||||
export async function processShelfBooks(
|
||||
provider: string,
|
||||
books: ShelfBook[],
|
||||
userId: string,
|
||||
shelfId: string,
|
||||
stats: ShelfSyncStats,
|
||||
log: LoggerType,
|
||||
maxLookups: number,
|
||||
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
||||
stats.booksFound += books.length;
|
||||
|
||||
let lookupsThisCycle = 0;
|
||||
const unlimitedLookups = maxLookups === 0;
|
||||
|
||||
for (const book of books) {
|
||||
let mapping = await prisma.bookMapping.findUnique({
|
||||
where: { provider_externalBookId: { provider, externalBookId: book.bookId } },
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue;
|
||||
|
||||
mapping = await performAudibleLookup(provider, book, log);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
if (!mapping?.audibleAsin) continue;
|
||||
}
|
||||
|
||||
if (mapping.noMatch) {
|
||||
if (mapping.lastSearchAt) {
|
||||
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
|
||||
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
|
||||
mapping = await performAudibleLookup(provider, book, log, mapping.id);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
if (!mapping?.audibleAsin) continue;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.audibleAsin) {
|
||||
try {
|
||||
const result = await createRequestForUser(userId, {
|
||||
asin: mapping.audibleAsin,
|
||||
title: mapping.title,
|
||||
author: mapping.author,
|
||||
coverArtUrl: mapping.coverUrl || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
stats.requestsCreated++;
|
||||
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enrichBookCovers(provider, books);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich book list with cached cover URLs from AudibleCache.
|
||||
* Returns up to 8 books with the best available cover URL.
|
||||
*/
|
||||
async function enrichBookCovers(
|
||||
provider: string,
|
||||
books: ShelfBook[],
|
||||
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
||||
const bookIds = books.map(b => b.bookId);
|
||||
const mappings = bookIds.length > 0
|
||||
? await prisma.bookMapping.findMany({
|
||||
where: { provider, externalBookId: { in: bookIds } },
|
||||
select: { externalBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
|
||||
})
|
||||
: [];
|
||||
const mappingsByBookId = new Map(mappings.map(m => [m.externalBookId, m]));
|
||||
|
||||
const matchedAsins = mappings
|
||||
.map(m => m.audibleAsin)
|
||||
.filter((asin): asin is string => !!asin);
|
||||
const cachedCovers = matchedAsins.length > 0
|
||||
? await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: matchedAsins } },
|
||||
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
|
||||
})
|
||||
: [];
|
||||
const coverByAsin = new Map(
|
||||
cachedCovers
|
||||
.filter(c => c.cachedCoverPath || c.coverArtUrl)
|
||||
.map(c => {
|
||||
let coverUrl = c.coverArtUrl || '';
|
||||
if (c.cachedCoverPath) {
|
||||
const filename = c.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
return [c.asin, coverUrl] as const;
|
||||
})
|
||||
);
|
||||
|
||||
return books
|
||||
.map(b => {
|
||||
const mapping = mappingsByBookId.get(b.bookId);
|
||||
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
|
||||
if (!coverUrl) return null;
|
||||
return {
|
||||
coverUrl,
|
||||
asin: mapping?.audibleAsin || null,
|
||||
title: mapping?.title || b.title,
|
||||
author: mapping?.author || b.author,
|
||||
};
|
||||
})
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Audible for a book, persist the result to the unified BookMapping table.
|
||||
*/
|
||||
async function performAudibleLookup(
|
||||
provider: string,
|
||||
book: ShelfBook,
|
||||
log: LoggerType,
|
||||
existingMappingId?: string,
|
||||
): Promise<BookMapping | null> {
|
||||
const audibleService = getAudibleService();
|
||||
|
||||
try {
|
||||
const fullQuery = `${book.title} ${book.author}`;
|
||||
log.info(`Searching Audible for: "${fullQuery}"`);
|
||||
|
||||
let searchResult = await audibleService.search(fullQuery);
|
||||
let firstResult = searchResult.results[0];
|
||||
|
||||
if (!firstResult?.asin) {
|
||||
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
|
||||
if (cleanTitle !== book.title) {
|
||||
const cleanQuery = `${cleanTitle} ${book.author}`;
|
||||
log.info(`No results with full title, retrying without series info: "${cleanQuery}"`);
|
||||
searchResult = await audibleService.search(cleanQuery);
|
||||
firstResult = searchResult.results[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (firstResult?.asin) {
|
||||
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
|
||||
|
||||
const data = {
|
||||
title: firstResult.title,
|
||||
author: firstResult.author,
|
||||
audibleAsin: firstResult.asin,
|
||||
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
|
||||
noMatch: false,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.bookMapping.update({ where: { id: existingMappingId }, data });
|
||||
}
|
||||
return prisma.bookMapping.create({
|
||||
data: { provider, externalBookId: book.bookId, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`No Audible match for "${book.title}" by ${book.author}`);
|
||||
|
||||
const noMatchData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
audibleAsin: null,
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.bookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
|
||||
}
|
||||
return prisma.bookMapping.create({
|
||||
data: { provider, externalBookId: book.bookId, ...noMatchData },
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
const errorData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.bookMapping.update({ where: { id: existingMappingId }, data: errorData });
|
||||
}
|
||||
return prisma.bookMapping.create({
|
||||
data: { provider, externalBookId: book.bookId, ...errorData },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Component: Watched Lists Service
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*
|
||||
* Checks watched series and watched authors for new releases.
|
||||
* Deduplicates results using the works table, checks against user's library,
|
||||
* and auto-creates requests via the shared request-creator service.
|
||||
* Follows the same pattern as goodreads-sync.service.ts.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getAudibleService, AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('WatchedLists');
|
||||
|
||||
/** Max books to process per series (avoid excessively long runs) */
|
||||
const MAX_BOOKS_PER_SERIES = 200;
|
||||
|
||||
/** Max author book pages to scrape */
|
||||
const MAX_AUTHOR_PAGES = 4;
|
||||
|
||||
/** Delay between scrapes to avoid rate limiting (ms) */
|
||||
const SCRAPE_DELAY_MS = 2000;
|
||||
|
||||
export interface WatchedListsSyncStats {
|
||||
seriesChecked: number;
|
||||
authorsChecked: number;
|
||||
booksFound: number;
|
||||
requestsCreated: number;
|
||||
skippedOwned: number;
|
||||
skippedExisting: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface WatchedListsSyncOptions {
|
||||
/** Process only this specific user (for targeted sync) */
|
||||
userId?: string;
|
||||
/** Process only this specific series (for immediate sync on watch) */
|
||||
seriesAsin?: string;
|
||||
/** Process only this specific author (for immediate sync on watch) */
|
||||
authorAsin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all watched series and authors: scrape for new releases,
|
||||
* deduplicate, check library ownership, and create requests.
|
||||
* Called from the check_watched_lists processor.
|
||||
*/
|
||||
export async function processWatchedLists(
|
||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||
options: WatchedListsSyncOptions = {}
|
||||
): Promise<WatchedListsSyncStats> {
|
||||
const log = jobLogger || logger;
|
||||
const stats: WatchedListsSyncStats = {
|
||||
seriesChecked: 0,
|
||||
authorsChecked: 0,
|
||||
booksFound: 0,
|
||||
requestsCreated: 0,
|
||||
skippedOwned: 0,
|
||||
skippedExisting: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// ---- Watched Series ----
|
||||
await processAllWatchedSeries(log, stats, options);
|
||||
|
||||
// ---- Watched Authors ----
|
||||
await processAllWatchedAuthors(log, stats, options);
|
||||
|
||||
log.info('Watched lists sync complete', {
|
||||
seriesChecked: stats.seriesChecked,
|
||||
authorsChecked: stats.authorsChecked,
|
||||
booksFound: stats.booksFound,
|
||||
requestsCreated: stats.requestsCreated,
|
||||
skippedOwned: stats.skippedOwned,
|
||||
skippedExisting: stats.skippedExisting,
|
||||
errors: stats.errors,
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Series
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processAllWatchedSeries(
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats,
|
||||
options: WatchedListsSyncOptions
|
||||
): Promise<void> {
|
||||
const whereClause: any = {};
|
||||
if (options.userId) whereClause.userId = options.userId;
|
||||
if (options.seriesAsin) whereClause.seriesAsin = options.seriesAsin;
|
||||
const watchedSeries = await prisma.watchedSeries.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
if (watchedSeries.length === 0) {
|
||||
log.info('No watched series to process');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by seriesAsin to avoid re-scraping the same series for multiple users
|
||||
const seriesByAsin = new Map<string, typeof watchedSeries>();
|
||||
for (const ws of watchedSeries) {
|
||||
const list = seriesByAsin.get(ws.seriesAsin) || [];
|
||||
list.push(ws);
|
||||
seriesByAsin.set(ws.seriesAsin, list);
|
||||
}
|
||||
|
||||
log.info(`Processing ${seriesByAsin.size} unique watched series (${watchedSeries.length} total subscriptions)`);
|
||||
|
||||
for (const [seriesAsin, subscriptions] of seriesByAsin) {
|
||||
try {
|
||||
await processSeriesForUsers(seriesAsin, subscriptions, log, stats);
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
log.error(`Failed to process watched series ${seriesAsin}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit between series
|
||||
await delay(SCRAPE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function processSeriesForUsers(
|
||||
seriesAsin: string,
|
||||
subscriptions: Array<{ id: string; seriesTitle: string; user: { id: string; plexUsername: string } }>,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
const title = subscriptions[0].seriesTitle;
|
||||
log.info(`Scraping watched series: "${title}" (${seriesAsin})`);
|
||||
|
||||
// Scrape all pages of the series (up to MAX_BOOKS_PER_SERIES)
|
||||
const allBooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore && allBooks.length < MAX_BOOKS_PER_SERIES) {
|
||||
const result = await scrapeSeriesPage(seriesAsin, page);
|
||||
if (!result || result.books.length === 0) break;
|
||||
|
||||
allBooks.push(...result.books);
|
||||
hasMore = result.hasMore;
|
||||
page++;
|
||||
|
||||
if (hasMore) await delay(1000);
|
||||
}
|
||||
|
||||
if (allBooks.length === 0) {
|
||||
log.info(`No books found for series "${title}"`);
|
||||
stats.seriesChecked++;
|
||||
return;
|
||||
}
|
||||
|
||||
stats.booksFound += allBooks.length;
|
||||
|
||||
// Deduplicate
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(allBooks);
|
||||
|
||||
// Persist dedup groups (fire-and-forget)
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// For each user watching this series, create requests for new books
|
||||
for (const subscription of subscriptions) {
|
||||
await createRequestsForUser(
|
||||
subscription.user.id,
|
||||
subscription.user.plexUsername,
|
||||
dedupedBooks,
|
||||
log,
|
||||
stats
|
||||
);
|
||||
|
||||
// Update lastCheckedAt
|
||||
await prisma.watchedSeries.update({
|
||||
where: { id: subscription.id },
|
||||
data: { lastCheckedAt: new Date() },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
stats.seriesChecked++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Authors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processAllWatchedAuthors(
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats,
|
||||
options: WatchedListsSyncOptions
|
||||
): Promise<void> {
|
||||
const whereClause: any = {};
|
||||
if (options.userId) whereClause.userId = options.userId;
|
||||
if (options.authorAsin) whereClause.authorAsin = options.authorAsin;
|
||||
const watchedAuthors = await prisma.watchedAuthor.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
if (watchedAuthors.length === 0) {
|
||||
log.info('No watched authors to process');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by authorAsin to avoid re-scraping the same author for multiple users
|
||||
const authorsByAsin = new Map<string, typeof watchedAuthors>();
|
||||
for (const wa of watchedAuthors) {
|
||||
const list = authorsByAsin.get(wa.authorAsin) || [];
|
||||
list.push(wa);
|
||||
authorsByAsin.set(wa.authorAsin, list);
|
||||
}
|
||||
|
||||
log.info(`Processing ${authorsByAsin.size} unique watched authors (${watchedAuthors.length} total subscriptions)`);
|
||||
|
||||
for (const [authorAsin, subscriptions] of authorsByAsin) {
|
||||
try {
|
||||
await processAuthorForUsers(authorAsin, subscriptions, log, stats);
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
log.error(`Failed to process watched author ${authorAsin}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit between authors
|
||||
await delay(SCRAPE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function processAuthorForUsers(
|
||||
authorAsin: string,
|
||||
subscriptions: Array<{ id: string; authorName: string; user: { id: string; plexUsername: string } }>,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
const authorName = subscriptions[0].authorName;
|
||||
log.info(`Scraping watched author: "${authorName}" (${authorAsin})`);
|
||||
|
||||
const audibleService = getAudibleService();
|
||||
const allBooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore && page <= MAX_AUTHOR_PAGES) {
|
||||
try {
|
||||
const result = await audibleService.searchByAuthorAsin(authorName, authorAsin, page);
|
||||
if (result.books.length === 0) break;
|
||||
|
||||
allBooks.push(...result.books);
|
||||
hasMore = result.hasMore;
|
||||
page++;
|
||||
|
||||
if (hasMore) await delay(1000);
|
||||
} catch (error) {
|
||||
log.error(`Failed to scrape author page ${page} for "${authorName}"`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allBooks.length === 0) {
|
||||
log.info(`No books found for author "${authorName}"`);
|
||||
stats.authorsChecked++;
|
||||
return;
|
||||
}
|
||||
|
||||
stats.booksFound += allBooks.length;
|
||||
|
||||
// Deduplicate
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(allBooks);
|
||||
|
||||
// Persist dedup groups (fire-and-forget)
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// For each user watching this author, create requests for new books
|
||||
for (const subscription of subscriptions) {
|
||||
await createRequestsForUser(
|
||||
subscription.user.id,
|
||||
subscription.user.plexUsername,
|
||||
dedupedBooks,
|
||||
log,
|
||||
stats
|
||||
);
|
||||
|
||||
// Update lastCheckedAt
|
||||
await prisma.watchedAuthor.update({
|
||||
where: { id: subscription.id },
|
||||
data: { lastCheckedAt: new Date() },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
stats.authorsChecked++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared: Create requests for a user from a list of books
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createRequestsForUser(
|
||||
userId: string,
|
||||
username: string,
|
||||
books: AudibleAudiobook[],
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
// Filter to books that have an ASIN
|
||||
const booksWithAsin = books.filter(b => b.asin);
|
||||
if (booksWithAsin.length === 0) return;
|
||||
|
||||
// Batch check: which ASINs are already in library (direct + sibling expansion)
|
||||
const ownedAsins = await getOwnedAsins(booksWithAsin.map(b => b.asin));
|
||||
|
||||
for (const book of booksWithAsin) {
|
||||
// Skip if user already owns this (direct or via sibling ASIN)
|
||||
if (ownedAsins.has(book.asin)) {
|
||||
stats.skippedOwned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createRequestForUser(userId, {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator,
|
||||
description: book.description,
|
||||
coverArtUrl: book.coverArtUrl,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
stats.requestsCreated++;
|
||||
log.info(`Auto-requested "${book.title}" by ${book.author} for ${username}`);
|
||||
} else {
|
||||
// already_available, being_processed, duplicate — all expected
|
||||
stats.skippedExisting++;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to create request for "${book.title}" for ${username}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of ASINs that are already in the library (direct match + sibling expansion).
|
||||
*/
|
||||
async function getOwnedAsins(asins: string[]): Promise<Set<string>> {
|
||||
const owned = new Set<string>();
|
||||
|
||||
// Direct library lookup
|
||||
const libraryItems = await prisma.plexLibrary.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: { asin: true },
|
||||
});
|
||||
for (const item of libraryItems) {
|
||||
if (item.asin) owned.add(item.asin);
|
||||
}
|
||||
|
||||
// Sibling expansion via works table
|
||||
try {
|
||||
const siblingMap = await getSiblingAsins(asins);
|
||||
if (siblingMap.size > 0) {
|
||||
const allSiblings = new Set<string>();
|
||||
for (const siblings of siblingMap.values()) {
|
||||
for (const s of siblings) allSiblings.add(s);
|
||||
}
|
||||
|
||||
if (allSiblings.size > 0) {
|
||||
const siblingLibrary = await prisma.plexLibrary.findMany({
|
||||
where: { asin: { in: [...allSiblings] } },
|
||||
select: { asin: true },
|
||||
});
|
||||
|
||||
for (const item of siblingLibrary) {
|
||||
if (item.asin) {
|
||||
// Mark the original ASIN as owned (not the sibling)
|
||||
for (const [originalAsin, siblings] of siblingMap) {
|
||||
if (siblings.includes(item.asin)) {
|
||||
owned.add(originalAsin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Works table expansion is best-effort
|
||||
}
|
||||
|
||||
return owned;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Component: Works Service
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Manages the works table — persistent cross-ASIN audiobook identity mapping.
|
||||
* Layer 1: Auto-populated from dedup logic when users browse search/author/series pages.
|
||||
* Layer 2: Seeded at request time to ensure requested ASINs are tracked.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('WorksService');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 1: Persist dedup groups (fire-and-forget from API routes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Persist dedup groups to the works table. For each group of 2+ ASINs that
|
||||
* were identified as the same audiobook, create or update a Work record
|
||||
* linking all ASINs together.
|
||||
*
|
||||
* Safe to call fire-and-forget — never throws.
|
||||
*/
|
||||
export async function persistDedupGroups(groups: DedupGroup[]): Promise<void> {
|
||||
try {
|
||||
for (const group of groups) {
|
||||
await persistSingleGroup(group);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist dedup groups', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
groupCount: groups.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a single dedup group. Handles merging when ASINs span multiple
|
||||
* existing works.
|
||||
*/
|
||||
async function persistSingleGroup(group: DedupGroup): Promise<void> {
|
||||
const { canonicalAsin, allAsins, title, author, narrator, durationMinutes } = group;
|
||||
|
||||
// Find which of these ASINs already exist in work_asins
|
||||
const existingEntries = await prisma.workAsin.findMany({
|
||||
where: { asin: { in: allAsins } },
|
||||
select: { asin: true, workId: true },
|
||||
});
|
||||
|
||||
// Collect unique work IDs that already contain any of our ASINs
|
||||
const existingWorkIds = [...new Set(existingEntries.map(e => e.workId))];
|
||||
const existingAsinSet = new Set(existingEntries.map(e => e.asin));
|
||||
|
||||
if (existingWorkIds.length === 0) {
|
||||
// No existing works — create a new one with all ASINs
|
||||
const work = await prisma.work.create({
|
||||
data: { title, author },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
allAsins.map(asin =>
|
||||
prisma.workAsin.create({
|
||||
data: {
|
||||
workId: work.id,
|
||||
asin,
|
||||
narrator: asin === canonicalAsin ? narrator : undefined,
|
||||
durationMinutes: asin === canonicalAsin ? durationMinutes : undefined,
|
||||
isCanonical: asin === canonicalAsin,
|
||||
source: 'dedup_auto',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
logger.debug('Created new work', { workId: work.id, asinCount: allAsins.length });
|
||||
} else {
|
||||
// Use the first existing work as the target
|
||||
const targetWorkId = existingWorkIds[0];
|
||||
|
||||
// If multiple existing works, merge them into the target
|
||||
if (existingWorkIds.length > 1) {
|
||||
const mergeWorkIds = existingWorkIds.slice(1);
|
||||
|
||||
// Move all ASINs from other works to the target
|
||||
await prisma.workAsin.updateMany({
|
||||
where: { workId: { in: mergeWorkIds } },
|
||||
data: { workId: targetWorkId },
|
||||
});
|
||||
|
||||
// Delete the now-empty works
|
||||
await prisma.work.deleteMany({
|
||||
where: { id: { in: mergeWorkIds } },
|
||||
});
|
||||
|
||||
logger.debug('Merged works', {
|
||||
targetWorkId,
|
||||
mergedWorkIds: mergeWorkIds,
|
||||
});
|
||||
}
|
||||
|
||||
// Add any new ASINs that don't already exist
|
||||
const newAsins = allAsins.filter(a => !existingAsinSet.has(a));
|
||||
if (newAsins.length > 0) {
|
||||
await Promise.all(
|
||||
newAsins.map(asin =>
|
||||
prisma.workAsin.create({
|
||||
data: {
|
||||
workId: targetWorkId,
|
||||
asin,
|
||||
narrator: asin === canonicalAsin ? narrator : undefined,
|
||||
durationMinutes: asin === canonicalAsin ? durationMinutes : undefined,
|
||||
isCanonical: asin === canonicalAsin,
|
||||
source: 'dedup_auto',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
logger.debug('Added ASINs to existing work', {
|
||||
workId: targetWorkId,
|
||||
newAsinCount: newAsins.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Update canonical status: ensure the canonical ASIN is marked
|
||||
await prisma.workAsin.updateMany({
|
||||
where: { workId: targetWorkId, asin: canonicalAsin },
|
||||
data: { isCanonical: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 2: Seed ASIN at request time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ensure an ASIN is tracked in the works table. Creates a single-ASIN work
|
||||
* if the ASIN isn't already present. Called at request creation time.
|
||||
*
|
||||
* Safe to call fire-and-forget — never throws.
|
||||
*/
|
||||
export async function seedAsin(
|
||||
asin: string,
|
||||
title: string,
|
||||
author: string,
|
||||
narrator?: string,
|
||||
durationMinutes?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if ASIN already tracked
|
||||
const existing = await prisma.workAsin.findUnique({
|
||||
where: { asin },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
// Create a new single-ASIN work
|
||||
const work = await prisma.work.create({
|
||||
data: { title, author },
|
||||
});
|
||||
|
||||
await prisma.workAsin.create({
|
||||
data: {
|
||||
workId: work.id,
|
||||
asin,
|
||||
narrator,
|
||||
durationMinutes,
|
||||
isCanonical: true,
|
||||
source: 'dedup_auto',
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('Seeded ASIN', { workId: work.id, asin });
|
||||
} catch (error) {
|
||||
logger.error('Failed to seed ASIN', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
asin,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sibling ASIN lookup (for library matching expansion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Given a list of ASINs, return a map of each input ASIN to its sibling ASINs
|
||||
* (other ASINs in the same work, NOT including the input ASIN itself).
|
||||
*
|
||||
* ASINs not found in the works table are simply omitted from the result.
|
||||
*/
|
||||
export async function getSiblingAsins(
|
||||
asins: string[]
|
||||
): Promise<Map<string, string[]>> {
|
||||
const result = new Map<string, string[]>();
|
||||
if (asins.length === 0) return result;
|
||||
|
||||
// Step 1: Find which input ASINs are in work_asins and their work IDs
|
||||
const inputEntries = await prisma.workAsin.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: { asin: true, workId: true },
|
||||
});
|
||||
|
||||
if (inputEntries.length === 0) return result;
|
||||
|
||||
// Build map of workId -> input ASINs in that work
|
||||
const workIdToInputAsins = new Map<string, string[]>();
|
||||
for (const entry of inputEntries) {
|
||||
const list = workIdToInputAsins.get(entry.workId);
|
||||
if (list) {
|
||||
list.push(entry.asin);
|
||||
} else {
|
||||
workIdToInputAsins.set(entry.workId, [entry.asin]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Get ALL ASINs in those works
|
||||
const workIds = [...workIdToInputAsins.keys()];
|
||||
const allWorkAsins = await prisma.workAsin.findMany({
|
||||
where: { workId: { in: workIds } },
|
||||
select: { asin: true, workId: true },
|
||||
});
|
||||
|
||||
// Build map of workId -> all ASINs
|
||||
const workIdToAllAsins = new Map<string, string[]>();
|
||||
for (const entry of allWorkAsins) {
|
||||
const list = workIdToAllAsins.get(entry.workId);
|
||||
if (list) {
|
||||
list.push(entry.asin);
|
||||
} else {
|
||||
workIdToAllAsins.set(entry.workId, [entry.asin]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: For each input ASIN, compute siblings (all ASINs in same work minus self)
|
||||
for (const entry of inputEntries) {
|
||||
const allInWork = workIdToAllAsins.get(entry.workId) || [];
|
||||
const siblings = allInWork.filter(a => a !== entry.asin);
|
||||
if (siblings.length > 0) {
|
||||
result.set(entry.asin, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Component: API Token Type Definitions
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
/** Base API token as returned by user-facing endpoints */
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
role: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Extended API token with cross-user fields, returned by admin endpoints */
|
||||
export interface AdminApiToken extends ApiToken {
|
||||
createdBy: string;
|
||||
createdById: string;
|
||||
tokenUser: string;
|
||||
tokenUserId: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Component: API Token Generation Utility
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { API_TOKEN_PREFIX, TOKEN_RANDOM_BYTES, TOKEN_PREFIX_LENGTH } from '../constants/api-tokens';
|
||||
|
||||
interface GeneratedToken {
|
||||
/** The full token string to return to the user (shown only once) */
|
||||
fullToken: string;
|
||||
/** SHA-256 hash of the full token (stored in database) */
|
||||
tokenHash: string;
|
||||
/** Display prefix for identification (first 12 chars) */
|
||||
tokenPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API token with its hash and display prefix.
|
||||
* The full token is: API_TOKEN_PREFIX + random hex string.
|
||||
* Only the hash is stored; the full token is returned once at creation.
|
||||
*/
|
||||
export function generateApiToken(): GeneratedToken {
|
||||
const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex');
|
||||
const fullToken = `${API_TOKEN_PREFIX}${randomPart}`;
|
||||
const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex');
|
||||
const tokenPrefix = fullToken.substring(0, TOKEN_PREFIX_LENGTH);
|
||||
|
||||
return { fullToken, tokenHash, tokenPrefix };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Component: API Token Rate Limiting
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
|
||||
* to prevent unbounded memory growth.
|
||||
*/
|
||||
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** Number of checkRateLimit calls since the last full sweep */
|
||||
let checkCount = 0;
|
||||
|
||||
/** How often (in calls) to perform a full sweep of expired buckets */
|
||||
const SWEEP_INTERVAL = 100;
|
||||
|
||||
/**
|
||||
* Sweep the entire bucket map and delete all expired entries.
|
||||
* Called automatically every SWEEP_INTERVAL checks.
|
||||
*/
|
||||
function sweepExpiredBuckets(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (now >= bucket.resetAt) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||
checkCount += 1;
|
||||
if (checkCount >= SWEEP_INTERVAL) {
|
||||
checkCount = 0;
|
||||
sweepExpiredBuckets();
|
||||
}
|
||||
|
||||
const current = buckets.get(key);
|
||||
|
||||
// Lazy eviction: if the bucket is expired, delete it and start fresh
|
||||
if (!current || now >= current.resetAt) {
|
||||
if (current) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
|
||||
}
|
||||
|
||||
if (current.count >= maxRequests) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
current.count += 1;
|
||||
return {
|
||||
allowed: true,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
|
||||
}
|
||||
|
||||
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
||||
}
|
||||
|
||||
/** Reset all buckets and the sweep counter. For testing only. */
|
||||
export function _resetBuckets(): void {
|
||||
buckets.clear();
|
||||
checkCount = 0;
|
||||
}
|
||||
|
||||
/** Get the current number of tracked buckets. For testing only. */
|
||||
export function _getBucketCount(): number {
|
||||
return buckets.size;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { LibraryItem } from '@/lib/services/library';
|
||||
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
// Module-level logger
|
||||
@@ -178,6 +179,61 @@ export async function enrichAudiobooksWithMatches(
|
||||
}
|
||||
}
|
||||
|
||||
// Works-table sibling expansion: check if unmatched ASINs have siblings in the library
|
||||
try {
|
||||
const unmatchedAsins = results.filter(r => !r.isAvailable).map(r => r.asin);
|
||||
if (unmatchedAsins.length > 0) {
|
||||
const siblingMap = await getSiblingAsins(unmatchedAsins);
|
||||
if (siblingMap.size > 0) {
|
||||
// Collect all sibling ASINs for a single batch library query
|
||||
const allSiblingAsins = new Set<string>();
|
||||
for (const siblings of siblingMap.values()) {
|
||||
for (const s of siblings) allSiblingAsins.add(s);
|
||||
}
|
||||
|
||||
if (allSiblingAsins.size > 0) {
|
||||
const siblingLibraryMatches = await prisma.plexLibrary.findMany({
|
||||
where: { asin: { in: [...allSiblingAsins] } },
|
||||
select: { asin: true, plexGuid: true },
|
||||
});
|
||||
const libraryAsinSet = new Set(
|
||||
siblingLibraryMatches.filter(m => m.asin).map(m => m.asin!.toLowerCase())
|
||||
);
|
||||
|
||||
// Update results where a sibling ASIN is found in the library
|
||||
for (const result of results) {
|
||||
if (result.isAvailable) continue;
|
||||
const siblings = siblingMap.get(result.asin);
|
||||
if (!siblings) continue;
|
||||
const matchedSiblingAsin = siblings.find(s => libraryAsinSet.has(s.toLowerCase()));
|
||||
if (matchedSiblingAsin) {
|
||||
const libMatch = siblingLibraryMatches.find(
|
||||
m => m.asin?.toLowerCase() === matchedSiblingAsin.toLowerCase()
|
||||
);
|
||||
(result as any).isAvailable = true;
|
||||
(result as any).plexGuid = libMatch?.plexGuid || null;
|
||||
}
|
||||
}
|
||||
|
||||
const siblingMatchCount = results.filter(r => {
|
||||
if (!r.isAvailable) return false;
|
||||
return siblingMap.has(r.asin);
|
||||
}).length;
|
||||
logger.debug('Sibling expansion', {
|
||||
unmatchedCount: unmatchedAsins.length,
|
||||
siblingGroupsFound: siblingMap.size,
|
||||
siblingMatches: siblingMatchCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Works table expansion is best-effort — direct matches still work
|
||||
logger.error('Sibling ASIN expansion failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Always enrich with request status (check ANY user's requests)
|
||||
const asins = audiobooks.map(book => book.asin);
|
||||
|
||||
@@ -272,6 +328,57 @@ export async function enrichAudiobooksWithMatches(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ASINs that are considered "available" — present in library or have completed requests.
|
||||
* Used by paginated API routes to exclude available items at the DB level.
|
||||
*/
|
||||
export async function getAvailableAsins(): Promise<Set<string>> {
|
||||
const [libraryItems, completedRequests] = await Promise.all([
|
||||
// ASINs present in the library (Plex or Audiobookshelf)
|
||||
prisma.plexLibrary.findMany({
|
||||
where: { asin: { not: null } },
|
||||
select: { asin: true },
|
||||
distinct: ['asin'],
|
||||
}),
|
||||
// ASINs with completed audiobook requests
|
||||
prisma.audiobook.findMany({
|
||||
where: {
|
||||
audibleAsin: { not: null },
|
||||
requests: {
|
||||
some: {
|
||||
status: 'completed',
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { audibleAsin: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const asins = new Set<string>();
|
||||
for (const item of libraryItems) {
|
||||
if (item.asin) asins.add(item.asin);
|
||||
}
|
||||
for (const item of completedRequests) {
|
||||
if (item.audibleAsin) asins.add(item.audibleAsin);
|
||||
}
|
||||
|
||||
// Expand with works-table sibling ASINs
|
||||
try {
|
||||
if (asins.size > 0) {
|
||||
const siblingMap = await getSiblingAsins([...asins]);
|
||||
for (const siblings of siblingMap.values()) {
|
||||
for (const s of siblings) asins.add(s);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Works table expansion is best-effort
|
||||
}
|
||||
|
||||
return asins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize ISBN for comparison (remove dashes and spaces)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Component: Client-side URL Utilities
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the current instance origin URL.
|
||||
* Returns window.location.origin on the client, or a placeholder on the server.
|
||||
*/
|
||||
export function getInstanceUrl(): string {
|
||||
return typeof window !== 'undefined' ? window.location.origin : 'https://your-instance';
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Component: Audiobook Deduplication Utility
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Deduplicates audiobook listings that represent the same recording
|
||||
* under different ASINs (publisher re-listings, rights transfers, etc.).
|
||||
*
|
||||
* Dedup key: normalized title + normalized narrator
|
||||
* Duration tolerance: max(longerDuration * 0.01, 5) minutes
|
||||
* Missing duration treated as compatible (graceful degradation).
|
||||
*/
|
||||
|
||||
import type { AudibleAudiobook } from '../integrations/audible.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Title / narrator normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */
|
||||
const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi;
|
||||
|
||||
/** Trailing subtitle after colon or long dash */
|
||||
const SUBTITLE_RE = /\s*[:]\s+.+$/;
|
||||
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
|
||||
|
||||
/** Trailing descriptors like "A Novel", "A Memoir" */
|
||||
const TRAILING_DESCRIPTOR_RE = /\s*[-:,]?\s+a\s+(novel|memoir|thriller|mystery|romance|story|tale|novella)\s*$/i;
|
||||
|
||||
/**
|
||||
* Normalize a title for dedup comparison.
|
||||
* Strips subtitles, edition markers, and trailing descriptors.
|
||||
*/
|
||||
export function normalizeTitle(title: string): string {
|
||||
let t = title.toLowerCase();
|
||||
// Remove parenthesized/bracketed edition markers
|
||||
t = t.replace(EDITION_PAREN_RE, '');
|
||||
// Remove trailing descriptors before subtitle stripping
|
||||
t = t.replace(TRAILING_DESCRIPTOR_RE, '');
|
||||
// Remove subtitle after colon
|
||||
t = t.replace(SUBTITLE_RE, '');
|
||||
// Remove subtitle after long dash (but not short hyphenated words)
|
||||
t = t.replace(LONG_DASH_SUBTITLE_RE, '');
|
||||
// Collapse whitespace and trim
|
||||
return t.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
|
||||
function normalizeNarrator(narrator?: string): string {
|
||||
const raw = (narrator || '').toLowerCase().trim();
|
||||
if (!raw) return raw;
|
||||
return raw.split(',').map(n => n.trim()).filter(Boolean).sort().join(', ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Duration compatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if two durations are compatible (represent the same recording).
|
||||
* Tolerance: max(longerDuration * 0.01, 5) minutes.
|
||||
* Missing duration on either side is treated as compatible.
|
||||
*/
|
||||
export function areDurationsCompatible(a?: number, b?: number): boolean {
|
||||
if (a == null || b == null) return true;
|
||||
const longer = Math.max(a, b);
|
||||
const tolerance = Math.max(longer * 0.01, 5);
|
||||
return Math.abs(a - b) <= tolerance;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata scoring (for picking best representative)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function metadataScore(book: AudibleAudiobook): number {
|
||||
let score = 0;
|
||||
if (book.coverArtUrl) score++;
|
||||
if (book.rating != null) score++;
|
||||
if (book.durationMinutes != null) score++;
|
||||
if (book.description) score++;
|
||||
if (book.narrator) score++;
|
||||
if (book.releaseDate) score++;
|
||||
if (book.genres && book.genres.length > 0) score++;
|
||||
return score;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedup group types (for works-table persistence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Metadata about a group of ASINs that were collapsed during dedup. */
|
||||
export interface DedupGroup {
|
||||
canonicalAsin: string; // ASIN of the "winner" (best metadata score)
|
||||
allAsins: string[]; // All ASINs in this group (including canonical)
|
||||
title: string; // Author from the canonical entry
|
||||
author: string; // Author from the canonical entry
|
||||
narrator?: string; // Narrator from the canonical entry
|
||||
durationMinutes?: number; // Duration from the canonical entry
|
||||
}
|
||||
|
||||
/** Result of deduplication with group collection. */
|
||||
export interface DeduplicateResult {
|
||||
books: AudibleAudiobook[]; // The deduped list (same as deduplicateAudiobooks returns)
|
||||
groups: DedupGroup[]; // Groups where 2+ ASINs were collapsed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main dedup functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deduplicate audiobook listings by normalized title + narrator + duration.
|
||||
*
|
||||
* Same narrator + compatible duration + similar title = same recording -> collapse.
|
||||
* Different narrator = different production -> keep both.
|
||||
* Duration outside tolerance = different content (abridged vs unabridged) -> keep both.
|
||||
*
|
||||
* Preserves original ordering (position of first appearance).
|
||||
*/
|
||||
export function deduplicateAudiobooks(books: AudibleAudiobook[]): AudibleAudiobook[] {
|
||||
return deduplicateAndCollectGroups(books).books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate audiobooks AND return grouping metadata for works-table persistence.
|
||||
* Returns both the deduped list and the groups where 2+ ASINs were collapsed.
|
||||
*/
|
||||
export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): DeduplicateResult {
|
||||
if (books.length <= 1) return { books: [...books], groups: [] };
|
||||
|
||||
// Group by normalized title + narrator
|
||||
const titleNarratorGroups = new Map<string, AudibleAudiobook[]>();
|
||||
const insertionOrder: string[] = [];
|
||||
|
||||
for (const book of books) {
|
||||
const key = `${normalizeTitle(book.title)}|||${normalizeNarrator(book.narrator)}`;
|
||||
const group = titleNarratorGroups.get(key);
|
||||
if (group) {
|
||||
group.push(book);
|
||||
} else {
|
||||
titleNarratorGroups.set(key, [book]);
|
||||
insertionOrder.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const result: AudibleAudiobook[] = [];
|
||||
const dedupGroups: DedupGroup[] = [];
|
||||
|
||||
for (const key of insertionOrder) {
|
||||
const group = titleNarratorGroups.get(key)!;
|
||||
if (group.length === 1) {
|
||||
result.push(group[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Within a title+narrator group, further split by duration compatibility.
|
||||
// Build sub-groups where all members are duration-compatible with the
|
||||
// representative (first member). A book joins the first compatible sub-group.
|
||||
const subGroups: AudibleAudiobook[][] = [];
|
||||
|
||||
for (const book of group) {
|
||||
let placed = false;
|
||||
for (const sg of subGroups) {
|
||||
// Check compatibility against the representative (first member)
|
||||
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) {
|
||||
sg.push(book);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
subGroups.push([book]);
|
||||
}
|
||||
}
|
||||
|
||||
// From each sub-group, pick the best representative and collect group metadata
|
||||
for (const sg of subGroups) {
|
||||
let best = sg[0];
|
||||
let bestScore = metadataScore(best);
|
||||
for (let i = 1; i < sg.length; i++) {
|
||||
const score = metadataScore(sg[i]);
|
||||
if (score > bestScore) {
|
||||
best = sg[i];
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
result.push(best);
|
||||
|
||||
// Collect group metadata for works-table persistence (only multi-ASIN groups)
|
||||
if (sg.length >= 2) {
|
||||
dedupGroups.push({
|
||||
canonicalAsin: best.asin,
|
||||
allAsins: sg.map(b => b.asin),
|
||||
title: best.title,
|
||||
author: best.author,
|
||||
narrator: best.narrator,
|
||||
durationMinutes: best.durationMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { books: result, groups: dedupGroups };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Component: Runtime Parsing Utility
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Shared runtime/duration text parser extracted from AudibleService.
|
||||
* Handles all i18n patterns (English, German, Spanish, French) via
|
||||
* language-specific regex patterns in LanguageConfig.
|
||||
*/
|
||||
|
||||
import type { LanguageConfig } from '../constants/language-config';
|
||||
|
||||
/**
|
||||
* Parse runtime text (e.g. "12 hrs and 30 mins", "5 Std. 20 Min.")
|
||||
* into total minutes using language-specific patterns.
|
||||
*
|
||||
* @param runtimeText - Raw runtime string from Audible HTML
|
||||
* @param langConfig - Language configuration with hour/minute regex patterns
|
||||
* @returns Total minutes, or undefined if no duration could be parsed
|
||||
*/
|
||||
export function parseRuntime(runtimeText: string, langConfig: LanguageConfig): number | undefined {
|
||||
if (!runtimeText) return undefined;
|
||||
|
||||
let totalMinutes = 0;
|
||||
|
||||
// Try each hour pattern until one matches
|
||||
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
|
||||
const match = runtimeText.match(pattern);
|
||||
if (match) {
|
||||
totalMinutes += parseInt(match[1]) * 60;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each minute pattern until one matches
|
||||
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
|
||||
const match = runtimeText.match(pattern);
|
||||
if (match) {
|
||||
totalMinutes += parseInt(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user