Compare commits

..

80 Commits

Author SHA1 Message Date
kikootwo 6025ac200a Merge branch 'main' into feature/hardover-shelves 2026-03-04 23:16:08 -05:00
kikootwo 248bd5359c Merge pull request #130 from kikootwo/feature/api-tokens
Feature/api tokens
2026-03-04 23:11:21 -05:00
kikootwo 53c1e0dad7 Merge pull request #131 from borski/pr-130-review
feature/api_tokens review fixes: role enforcement security + UI bugfixes
2026-03-04 23:03:14 -05:00
Michael Borohovski 45c8b614e3 Remove role override UI since backend enforces user's actual role
The role override dropdown is now misleading since the backend rejects
any attempt to set a role that differs from the target user's actual role.
Removed the dropdown and added helper text explaining that the token
inherits the selected user's role.
2026-03-04 17:15:46 -08:00
Michael Borohovski 24aa6afefc Add tests for admin token creation role enforcement 2026-03-04 16:57:02 -08:00
Michael Borohovski 81813dc625 Fix token UI success handling, fetch error surfacing, and docs key stability 2026-03-04 16:53:11 -08:00
kikootwo f65cb59a9c Display AI recommendation reason in modal
Passes aiReason from the BookDate page into AudiobookDetailsModal and updates the modal to accept an optional aiReason prop (string | null). When provided, the modal renders a titled section "Why This Was Recommended" with styled content above the details grid. This includes prop/interface changes and a default value to preserve existing behavior when no reason is available.
2026-03-04 19:50:00 -05:00
kikootwo d1ea65a41a Use /admin/settings route and update RequestCard tests
Change the settings navigation in BookDatePage to push to /admin/settings and update the corresponding test to expect the new route. Simplify RequestCard tests by removing manual/interactive search mocks and modal, remove interactiveSearch permission from the mocked AuthContext, and adjust tests to only assert cancel behavior; add a new test ensuring Manual/Interactive Search buttons are not rendered. Misc: clean up related mock resets and removed a failing manual-search failure test.
2026-03-04 19:41:44 -05:00
Michael Borohovski a5e7af1a53 Harden admin token creation to enforce target user role 2026-03-04 16:27:52 -08:00
kikootwo ca02b8b6e7 Enable ebook interactive search and job routing
Add support for interactive ebook searches and streamline search job routing. Key changes:

- RequestActionsDropdown: loosened status checks for search/adjust actions, route interactive search to an ebook-specific modal when the request is an ebook, and pass request.customSearchTerms to the ebook search modal.
- API: interactive-search-ebook route now supports two flows (direct ebook requests and audiobook sidecar ebook searches), updates validation logic, checks for existing child ebook requests only in sidecar mode, and improves logging. manual-search route now dispatches addSearchEbookJob for ebook requests and addSearchJob for audiobooks.
- RequestCard: removed manual/interactive search UI, related hooks and modal usage (interactive search is handled via the admin dropdown/modal now).

These changes enable direct ebook interactive search flows, prevent invalid searches based on request type/status, and ensure the correct background job is enqueued per request type.
2026-03-04 16:32:09 -05:00
kikootwo 85aa80938a Remove AddShelfModal usage from Header
Remove AddShelfModal from the Header component: delete its import, the showAddShelfModal state hook, the "Add Shelf" menu button, and the AddShelfModal modal render. Cleans up unused state and UI related to adding shelves; no other behavior changes.
2026-03-04 16:00:36 -05:00
kikootwo efb4f64014 Count errors and skip shelf on token decrypt fail
When decrypting a user's API token fails, increment stats.errors and continue to the next shelf instead of proceeding. This ensures failed decryptions are tracked in metrics and prevents attempting to fetch data with an invalid token.
2026-03-04 15:53:50 -05:00
kikootwo 95917715b1 Remove redundant id field from JWT payloads
Drop the duplicated `id` alias from JWT payloads and related token generation across auth providers and endpoints. The TokenPayload interface no longer includes `id`; middleware now derives `user.id` from `sub` when attaching the authenticated user to requests. Update tests accordingly. This reduces redundancy and ensures the canonical user identifier is `sub`.
2026-03-04 15:36:28 -05:00
kikootwo a50fbc721e Add useApiTokens hook and refactor token UI
Introduce a shared useApiTokens hook to centralize API token CRUD and UI state (fetch, create, delete, copy, formatting). Refactor ApiTab and ApiTokensSection to consume the hook and remove duplicated logic. Add getInstanceUrl utility for client origin used in curl examples. Include an id alias in TokenPayload and add id into generated JWTs across auth routes and providers; update tests accordingly. Improve auth middleware typing and add debug logging around lastUsedAt updates. Add admin logging when creating a token with a role that differs from the target user's role.
2026-03-04 15:18:48 -05:00
kikootwo d6eca611fc Add API tokens management, docs & UI
Introduce full API token support: add a Prisma migration to create api_tokens table and indexes; add types, constants and a generateApiToken utility (hashed token + prefix). Update admin and user token routes to use the generator, enforce per-user active token caps, and integrate rate-limit checks. Add an interactive API docs page with TokenInput, EndpointCard and ResponseViewer components, plus a protected page route. Improve confirmation UX with an accessible ConfirmDialog (focus trap, Escape to close, animations) and wire confirm flows into admin/profile token sections; also update ConfirmModal to accept node messages. Add dialog CSS animations and enhance clipboard error handling. Update related middleware, utils and tests to reflect changes.
2026-03-04 14:51:23 -05:00
kikootwo 45e818c181 Merge pull request #127 from borski/feature/per-user-api-tokens
Add per-user API tokens with security hardening
2026-03-04 13:30:52 -05:00
kikootwo 85977d123c Merge branch 'main' into feature/per-user-api-tokens 2026-03-04 13:26:57 -05:00
kikootwo 441724c378 Normalize local usernames to lowercase
Normalize local account usernames by trimming and lowercasing across the stack. Added a Prisma migration to lowercase existing plex_username and rewrite local plex_id values for non-deleted accounts. Updated LocalAuthProvider, admin login route, and setup completion to use normalized usernames when looking up, creating, and storing users (including plexId `local-{username}`). Added/updated tests to assert case-insensitive lookups, storage of lowercased usernames/plexIds, and duplicate username rejection.
2026-03-04 12:47:09 -05:00
kikootwo d0ce485bdc Enrich audiobook metadata from Audnexus
Query Audnexus (Audible) to backfill missing metadata during manual imports and file organization. Adds getAudibleService imports and calls to fetch audiobook details by ASIN, then backfills series, seriesPart, seriesAsin, year (from releaseDate) and narrator when missing and updates the DB. Failures are non-fatal and logged; logs were added to surface enrichment steps. Also uses the resolved series/seriesPart when building organization metadata.
2026-03-04 12:19:37 -05:00
kikootwo c29cfa3a07 Fix token handling, modal behavior, and pagination
Multiple fixes and improvements:

- src/app/api/user/hardcover-shelves/[id]/route.ts: Make token testing more robust by using the existing shelf.apiToken when no new token is provided, attempt decryption only when needed, and gracefully fall back on decryption errors.
- src/components/ui/AddShelfModal.tsx: Simplify token handling by passing the trimmed token directly to addHardcover (remove client-side 'Bearer ' stripping).
- src/components/ui/ManageShelfModal.tsx: Stabilize form reset effect by depending on shelf?.id to avoid unnecessary re-renders when the shelf object changes identity.
- src/components/ui/Modal.tsx: Simplify modal rendering by removing the mounted state and createPortal usage, cleaning up imports and rendering directly.
- src/lib/services/hardcover-api.service.ts: Add a logger, introduce a MAX_PAGES cap and page counters to prevent unbounded pagination loops, and log/break when the API returns errors during pagination.

These changes improve reliability (token handling and pagination safety), reduce unnecessary renders, and simplify modal lifecycle.
2026-03-04 10:55:37 -05:00
kikootwo 7f706e806f Use hardcover-api service with pagination
Replace the old hardcover sync usage with a new hardcover-api.service implementation that adds types, a reusable extractBooks helper, and paginated GraphQL queries (limit/offset) to fully fetch status and list books. Update API route import to use the new service. Fix ManageShelfModal to initialize rssUrl/listId as empty strings. Update tests to mock the new service and add encryption format helper mocking.
2026-03-04 10:28:52 -05:00
kikootwo 338331d006 Add Hardcover shelf sync & unify book mappings
Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers.
2026-03-04 10:11:19 -05:00
kikootwo 6ca2e964e8 Merge branch 'main' into feature/hardover-shelves 2026-03-03 22:23:41 -05:00
kikootwo 1d1aaa7ff3 Merge pull request #126 from brombomb/hardcover-api
Unified Reading Shelves & Hardcover Integration
2026-03-03 22:13:32 -05:00
kikootwo cbf02d3e24 Add watched series/authors feature
Introduce watched lists for series and authors end-to-end.

- Add DB migration to create watched_series and watched_authors tables with indexes and foreign keys.
- Implement API routes: GET/POST for listing/adding and DELETE by id for both /api/user/watched-series and /api/user/watched-authors. Validation, ownership checks, and immediate targeted job triggers are included.
- Add client hooks (useWatchedSeries, useWatchedAuthors) with add/delete helpers and SWR revalidation.
- Add UI components: WatchButton (toggle/confirm) and WatchedListsSection for profile display and removal UX.
- Add processor (check-watched-lists.processor) and service (watched-lists.service) to scrape Audible, deduplicate, check library ownership, and auto-create requests; supports targeted checks for newly watched items.
- Include tests for the watched-lists service.

These changes implement the watched-lists feature to let users watch series/authors and have the system automatically detect and request new releases.
2026-03-03 21:57:38 -05:00
Michael Borohovski f0b2476b87 Add tests for security hardening: deleted user auth rejection, rate limiting 2026-03-03 15:47:19 -08:00
Michael Borohovski 04b6a2c135 Harden API token auth for deleted users and add route rate limiting 2026-03-03 15:16:03 -08:00
Rob Walsh 6da2c4ce95 Add tests 2026-03-03 13:39:52 -07:00
Rob Walsh ce8f4d642b fix hardcover images 2026-03-03 13:29:08 -07:00
Michael Borohovski 61b183542c Add per-user API tokens with admin override support
- Add userId field to ApiToken schema (the user identity the token acts as)
- Auth middleware resolves token identity via userId instead of createdById
- New /api/user/api-tokens routes for self-service token management
- Admin /api/admin/api-tokens routes support userId and role overrides
- API Tokens section on profile page for all users
- Admin API tab shows all tokens with user/role selectors
2026-03-03 12:23:57 -08:00
Rob Walsh ae4a73144d cleanup dep jobs 2026-03-03 13:20:28 -07:00
Rob Walsh c57d0c1492 Add a manage shelf modal 2026-03-03 13:16:23 -07:00
Rob Walsh 8f8387abff token encryption 2026-03-03 12:19:12 -07:00
Rob Walsh 4ae68d01de Encrypt Hardcover Api Token and fix failing tests 2026-03-03 11:51:38 -07:00
Rob Walsh 225ef8c919 Fix import to limit to 100, and scope to me for personal lists 2026-03-03 11:38:30 -07:00
kikootwo 610873af6b Add works table and ASIN deduping
Add persistent cross-ASIN "works" mapping and client-side deduplication to improve library matching. Introduces a Prisma migration and models (Work, WorkAsin) plus src/lib/services/works.service for persisting dedup groups, seeding ASINs at request time, and sibling lookup. Adds a deduplication utility (deduplicate-audiobooks) that normalizes titles/narrators, compares durations, and returns grouping metadata; API routes (search, author, series) now deduplicate results before enrichment and fire-and-forget persist groups. Adds sibling-ASIN expansion into audiobook matcher and expands getAvailableAsins accordingly. Extracts runtime parsing into a shared parse-runtime util and updates audible scrapers/services to use it. Includes unit tests for dedup logic and works service and updates test Prisma mocks.
2026-03-03 13:31:46 -05:00
kikootwo ff80d995c5 Add hideAvailable filter and unified pagination
Add support for hiding audiobooks that are already available by introducing a hideAvailable query flag and excluding matching ASINs at the DB level. Implemented getAvailableAsins() in audiobook-matcher to gather ASINs from the library and completed requests, and wired it into the popular and new-releases API routes to apply a notIn filter. Propagated the hideAvailable flag through useAudiobooks so client requests include the parameter, and adjusted the homepage to reset pagination when the flag changes. Replaced two StickyPagination instances with a new UnifiedPagination component (new file) that provides a single context-aware floating paginator which tracks the dominant section and allows switching between Popular and New Releases. Also removed client-side filtering in favor of server-side exclusion and made small imports/cleanup in page.tsx.
2026-03-03 12:36:03 -05:00
Rob Walsh e4e127880b fix modal 2026-03-02 21:12:34 -07:00
kikootwo bfd624e120 Bump package version to 1.0.16
Update package.json version from 1.0.15 to 1.0.16 to reflect a new patch release.
2026-03-02 17:06:01 -05:00
kikootwo b559835390 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-03-02 17:05:28 -05:00
kikootwo d25a6ebf79 Add custom search terms & retry download (admin)
Add support for per-request custom search terms and an admin retry-download flow.

- DB/schema: add custom_search_terms column via Prisma migration and schema update.
- Admin UI: new AdjustSearchTermsModal component and UI badges to show custom search status; RequestActionsDropdown and RecentRequestsTable updated to surface adjust/retry actions.
- API: new PATCH /api/admin/requests/[id]/search-terms to set/clear custom terms (optionally trigger a new search) and new POST /api/admin/requests/[id]/retry-download to resume monitoring or re-add downloads using DownloadHistory metadata.
- Behavior: interactive search now prefers customSearchTerms when present; manual import exposes cleanupSource option to organize job; admin requests listing returns downloadAttempts and customSearchTerms.
- UX: add SectionToolbar, LoadMoreBar and HideAvailableToggle components and wire hide-available preference across home, search, author and series pages; authors/series endpoints/page handlers gain pagination metadata.
- Misc: add connection-errors util and update related processors/services and tests to cover the new flows.

These changes enable admins to override search terms per request, trigger searches from the admin UI, and retry failed downloads more robustly.
2026-03-02 17:05:21 -05:00
kikootwo b3dad47aba Merge pull request #120 from brombomb/gemini
Add gemini bookdate support
2026-03-02 16:51:43 -05:00
Rob Walsh 7891e31893 Undo formatting noise 2026-03-02 13:58:11 -07:00
Rob Walsh bff74446fe Fix gemini key 2026-03-02 13:48:49 -07:00
Rob Walsh b940ad39f9 Better UX for Custom Lists 2026-03-02 13:45:16 -07:00
Rob Walsh f45f31b49c remove old file 2026-02-28 23:08:20 -07:00
Rob Walsh 978e177715 cleanup 2026-02-28 22:59:54 -07:00
Rob Walsh 038c92e49f Add gemini bookdate support 2026-02-28 22:55:59 -07:00
Rob Walsh 3861d07cf4 Remove boy scout formatting changes 2026-02-27 20:36:17 -07:00
Rob Walsh 41d45d1210 Refactor shelves UI and jobs 2026-02-27 15:46:10 -07:00
Rob Walsh cfe780c6f0 Hardcover API support 2026-02-27 15:10:27 -07:00
kikootwo 3ee67c8763 Bump package version to 1.0.15
Update package.json version from 1.0.14 to 1.0.15 to reflect a new release.
2026-02-27 12:15:42 -05:00
kikootwo edc56bc457 Add manual-import and download-access features
Introduce manual import workflow and download permission support. Adds a Prisma migration and schema field (users.download_access) to track per-user download access, and updates admin UI to toggle global and per-user download access. Implements new APIs: filesystem browse, manual-import endpoint, download-access settings, audiobook download-status, and on-demand download-token generation. Adds frontend components for manual import and related tests, plus documentation for the manual-import feature and the documentation-agent prompt. Key files: prisma/migrations/20260212000000_add_download_access_permission/migration.sql, prisma/schema.prisma, src/app/api/admin/filesystem/browse/route.ts, src/app/api/admin/manual-import/route.ts, src/app/api/admin/settings/download-access/route.ts, src/app/api/requests/[id]/download-token/route.ts, src/app/api/audiobooks/[asin]/download-status/route.ts, and updated admin users pages/components and permissions util.
2026-02-27 12:15:23 -05:00
kikootwo 73c5fe14e7 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-02-27 09:42:45 -05:00
kikootwo d9ccbfef5c Add optional bookdrop volume and .gitignore entry
Document an optional 'bookdrop' host folder in docker-compose.yml with a commented example volume mount for the Manual Import (Admin → audiobook → Manual Import) file picker, and add /bookdrop to .gitignore so local bookdrop mounts are not tracked.
2026-02-27 09:41:48 -05:00
kikootwo 01cac0e8e6 Merge pull request #115 from razzamatazm/fix/folder-organization-collisions
Fix file organizer collisions for nested duplicate audio names
2026-02-27 08:54:04 -05:00
kikootwo 66f4a215f7 Merge pull request #113 from razzamatazm/feature/direct-download-links
Add direct file download links to completed requests
2026-02-27 08:52:21 -05:00
razzamatazm 0bd9e88acc Fix organizer collisions for nested duplicate track names 2026-02-26 17:27:15 -08:00
razzamatazm f0b9bd2688 Fix organizer collisions for nested duplicate track names 2026-02-26 17:23:04 -08:00
razzamatazm e1629ce516 Address PR review: dedicated download secret, shared constants, strip filePath, streaming zip
- jwt.ts: Use JWT_DOWNLOAD_SECRET instead of JWT_SECRET for download tokens
- audio-formats.ts: Add EBOOK_EXTENSIONS export alongside AUDIO_EXTENSIONS
- request-statuses.ts: New shared COMPLETED_STATUSES constant used across requests API, download route, and RequestCard
- requests/route.ts: Import COMPLETED_STATUSES; strip filePath from audiobook in API response
- download/route.ts: Import format/status constants; add archiver dependency and replace adm-zip with streaming archiver for multi-file zips
- RequestCard.tsx: Use shared COMPLETED_STATUSES constant
2026-02-26 16:20:37 -08:00
razzamatazm 1006a04337 Add direct file download links to completed requests
Embeds a signed JWT download token (30-day expiry) in the requests API
response so users can download completed audiobook/ebook files directly
from the UI or by sharing the URL to apps like BookPlayer — no session
cookie required.

- jwt.ts: add generateDownloadToken / verifyDownloadToken helpers
- api/requests: append downloadUrl to completed requests with a filePath
- api/requests/[id]/download: new token-authenticated streaming endpoint;
  serves single files directly or zips multi-file audiobooks with adm-zip
- RequestCard: add Download link in the actions area for completed requests
2026-02-26 11:33:32 -08:00
kikootwo 547af71de8 Bump package version to 1.0.14
Update package.json version from 1.0.13 to 1.0.14 to reflect a new patch release. No other changes included in this commit.
2026-02-26 12:46:10 -05:00
kikootwo 1b0a80052d Use content_path and add savePath/path-wait
Always use qBittorrent's content_path as the canonical downloadPath and expose savePath on DownloadInfo instead of reconstructing paths from save_path + basename. Add path-waiting logic to the monitor: track consecutive pathWaitCount polls, re-queue the monitor with exponential-ish backoff while content_path remains outside save_path (to handle TempPathEnabled races), and give up after a configurable max attempts. Extend the MonitorDownload payload and JobQueue APIs to carry pathWaitCount. Organize-files processor now attempts to refresh the stored downloadPath from the download client and updates downloadHistory if the client reports a different path (applying path mapping). Update tests to reflect the new behavior and expectations.
2026-02-26 12:45:24 -05:00
kikootwo d38f03b8f4 Bump version to 1.0.13
Update package.json version from 1.0.12 to 1.0.13 to mark a new patch release.
2026-02-26 09:45:45 -05:00
kikootwo dbea15a34f Use content_path basename for finished torrents
When a torrent is finished (seeding/completed), build the download path from save_path combined with the basename of content_path instead of using torrent.name or the full content_path. This fixes a race with qBittorrent's TempPathEnabled (where content_path may still point to the temp dir) and addresses cases where the displayed torrent.name differs from the actual root folder/filename on disk. Added/updated tests to cover the TempPathEnabled race, name-mismatch scenarios, empty content_path fallback, and single-file torrents.
2026-02-26 09:45:23 -05:00
kikootwo 2972297903 Bump package version to 1.0.12
Update package.json version from 1.0.11 to 1.0.12 to publish a new patch release. No other changes were made in this commit.
2026-02-25 11:20:43 -05:00
kikootwo 03f82d4841 File rename templates & admin torrent approval
Add support for admin-driven interactive torrent selection and a file rename/template feature. Integrates an InteractiveTorrentSearchModal into the pending-approval admin UI, adds an admin approve flow that accepts an admin-selected torrent, and surfaces user/admin-selected torrent details in the UI. Introduces fileRenameEnabled and fileRenameTemplate settings (API + UI), persists them to configuration, and clears related caches. Pass renameConfig through the organize/organizeEbook flows and implement renaming in the FileOrganizer (single/multi-file handling). Enhance path-template utilities with conditional block resolution, filename-template validation, mock filename previews, and a buildRenamedFilename helper. Update tests to cover conditional templates and filename preview behavior.
2026-02-25 09:47:57 -05:00
kikootwo 33c2265e56 Use save_path for completed/seeding torrents
Resolve downloadPath using save_path for finished torrents to avoid TempPathEnabled race conditions where content_path can point to a stale temp location. Compute status once, treat 'seeding'/'completed' as finished, and prefer path.join(save_path, name) for those states while still using content_path (or falling back to save_path) for active downloads. Added tests covering multiple qBittorrent states (seeding/stalledUP/pausedUP/stoppedUP/forcedUP/queuedUP/downloading and empty content_path) and imported path in tests.
2026-02-24 02:03:20 -05:00
kikootwo b15a472bab Centralize download client timeout constant
Add DOWNLOAD_CLIENT_TIMEOUT (60000ms) in src/lib/constants/download-timeouts.ts and replace hardcoded 60000ms timeouts across Deluge, Prowlarr, qBittorrent and Transmission integrations. This centralizes the download/API timeout (gives headroom for indexers that enforce ~30s waits) and makes future adjustments easier without changing behavior.
2026-02-24 01:09:58 -05:00
kikootwo 3c680f2f38 Merge pull request #102 from Kikipeuk/ygg_timeout2
Extend the default timeout to add a torrent (Qbit, Transmission, Deluge)
2026-02-24 00:56:37 -05:00
kikootwo 16cd606421 Merge pull request #107 from kikootwo/feature-france-region
Feature france region
2026-02-24 00:53:01 -05:00
kikootwo 40d5363dc4 Fix French stopWords spacing and region name
Trim whitespace in the French stopWords array (add missing space after comma) to keep formatting consistent, and rename AUDIBLE_REGIONS.fr.name from "French" to "France" to better reflect the region label used for the Audible configuration.
2026-02-24 00:51:55 -05:00
kikootwo c138d8e642 Merge pull request #100 from Kikipeuk/french-traduction
Add French as Audible region
2026-02-24 00:40:50 -05:00
root 328fd8392b ygg_timeout2 2026-02-21 14:30:51 +01:00
root 9a460f808d french-Traduction 2026-02-21 13:57:47 +01:00
root c60b6214ce French Traduction 2026-02-21 12:44:56 +01:00
root aff5faaa58 French Traduction 2026-02-21 11:43:06 +01:00
root c43ce7ba8f French Traduction 2026-02-21 11:40:48 +01:00
root f570b87343 French Traduction 2026-02-21 10:48:24 +01:00
root dfa7a11674 French Traduction 2026-02-21 10:43:49 +01:00
201 changed files with 18423 additions and 1813 deletions
+4 -1
View File
@@ -1,5 +1,6 @@
# IDE
.idea
.vscode
# Dependencies
/node_modules
@@ -53,4 +54,6 @@ next-env.d.ts
/redis
/pgdata
/test-media
/test-data
/test-data
/bookdrop
dockerfile.patch
+5
View File
@@ -17,6 +17,11 @@ services:
- ./downloads:/downloads
- ./media:/media
# Book Drop: optional folder for Manual Import (Admin → audiobook → Manual Import)
# Map any host folder here and it will appear as a browsable root in the file picker.
# Example: - /path/to/your/audiobooks:/bookdrop
# - ./bookdrop:/bookdrop
# PostgreSQL data persistence
- ./pgdata:/var/lib/postgresql/data
+335
View File
@@ -0,0 +1,335 @@
# Documentation System Agent — Master Prompt
You are a documentation architect. Your job is to analyze a codebase from scratch and produce a **cascading, token-efficient documentation system** with a navigational index. When you are done, future AI agents dropped into this repo will be able to find any information they need by reading a single table of contents file, then following a link to exactly the right document — never wasting tokens reading irrelevant material.
---
## 1. What You Are Building
You are building three things:
### A. A `documentation/` directory
A tree of concise, AI-optimized markdown files that describe every meaningful part of the codebase. The structure mirrors the codebase's own architecture (backend services, frontend components, integrations, configuration, etc.) rather than imposing an arbitrary layout.
### B. A `documentation/TABLEOFCONTENTS.md` file
The **single entry point** for all documentation. This file maps natural-language questions and topic keywords to specific documentation files. Any agent that needs to understand something reads this file first, finds the 1-3 relevant docs, and reads only those. This is the most important file you will produce.
### C. A `CLAUDE.md` file at the project root
Project instructions that teach future agents how to use the documentation system. This file is automatically loaded into every Claude Code conversation, so it must be concise, directive, and self-contained.
---
## 2. The Token-Efficient Documentation Format
Every documentation file you create MUST follow this format. No exceptions.
### 2.1 Structure Template
```markdown
# [Title]
**Status:** [Implemented | Partial | Planned] — [One-line summary of what this is]
## Overview
[1-3 sentences. What is this? What does it do? Why does it exist?]
## Key Details
- Bullet points, not prose
- Data models: field names, types, constraints
- API endpoints: method, path, request/response shape
- Config keys and their values/defaults
- Enums, status values, important constants
- File paths and code locations
- Behavioral rules and edge cases
## API / Interfaces
[If applicable — tables or compact code blocks for endpoints, function signatures, event names, etc.]
## Dependencies
[What this depends on, and what depends on it — keep to a bullet list]
## Known Issues / Gotchas
[Only if there are real, non-obvious pitfalls. Omit section entirely if none.]
## Related
- [Link to related doc 1]
- [Link to related doc 2]
```
### 2.2 Format Rules
**REQUIRED — always include:**
- Status line with one-line summary
- API endpoints, data models, config keys (complete and accurate)
- File paths to source code (so agents can navigate directly)
- Enums, constants, and status values (exact strings/numbers)
- Dependency relationships between components
- Gotchas that have caused or could cause bugs
**FORBIDDEN — never include:**
- Verbose prose or narrative explanations
- "Why we chose X" sections (brief rationale in a bullet is fine)
- ASCII art diagrams larger than 5 lines
- More than 2 code examples per document
- "Future enhancements" or roadmap speculation
- "Testing strategy" sections (unless tests are the subject of the doc)
- "Performance considerations" (unless performance is the subject)
- Empty sections or placeholder text
- Decorative formatting, horizontal rules between every section, emoji
**TARGET:** Each doc file should be 30-80 lines. If it exceeds 120 lines, split it into sub-documents and link from a parent. The goal is ~70% fewer tokens than traditional documentation while preserving 100% of the technical details an agent needs.
---
## 3. The TABLEOFCONTENTS.md Format
This is the **router**. It maps questions to files. Format:
```markdown
# Table of Contents — [Project Name]
> **Read this file first.** Find your topic below, then read ONLY the linked files.
## Quick Reference
| Topic | File |
|-------|------|
| [Short topic] | [path/to/file.md] |
| ... | ... |
## By Category
### [Category Name] (e.g., "Authentication", "Database", "API Endpoints")
| Question / Topic | File(s) |
|-------------------|---------|
| How does [X] work? | [path.md] |
| What are the [Y] endpoints? | [path.md] |
| How is [Z] configured? | [path1.md], [path2.md] |
### [Next Category]
...
## Architecture Overview
[3-10 bullet points describing the high-level architecture — frameworks, major services, data flow. Just enough for an agent to orient itself before diving into specific docs.]
```
**Rules for TABLEOFCONTENTS.md:**
- Every documentation file MUST appear in at least one table row
- Questions should be phrased the way a developer or AI agent would actually ask them
- A single question can map to multiple files (e.g., "How do downloads work?" → `downloads.md`, `jobs.md`)
- A single file can appear under multiple questions
- Categories should match the codebase's actual domain boundaries, not generic labels
- The Architecture Overview section gives agents a 30-second orientation before they search for specifics
---
## 4. Execution Plan
Follow these phases in order. **Delegate heavily using the Task tool** — you should be orchestrating, not doing all the reading yourself.
### Phase 1: Deep Discovery (Delegate to Explore Agents)
Launch **3-5 parallel Explore agents** using the Task tool to map the entire codebase. Each agent should focus on a different area. Suggested splits:
**Agent 1 — Project Structure & Config:**
- Map the top-level directory tree (2-3 levels deep)
- Identify the tech stack (languages, frameworks, package managers)
- Read config files (package.json, tsconfig, docker-compose, .env.example, etc.)
- Identify build/deploy pipeline
- Note the entry points of the application
**Agent 2 — Backend / Server-Side:**
- Identify all backend services, controllers, routes, middleware
- Map API endpoints (paths, methods, handlers)
- Identify the database layer (ORM, schema files, migrations)
- Note background jobs, queues, cron tasks, workers
- Identify authentication/authorization mechanisms
**Agent 3 — Frontend / Client-Side:**
- Identify UI framework and component structure
- Map page routes and navigation
- Identify state management approach
- Note API client/service layer
- Identify shared components, layouts, hooks
**Agent 4 — Integrations & External Services:**
- Identify all third-party API integrations
- Map external service connections (databases, caches, message queues, cloud services)
- Note webhook handlers, OAuth flows, API keys
- Identify notification systems (email, push, SMS)
**Agent 5 — Data Layer & Business Logic:**
- Map database schema (tables/collections, relationships, key fields)
- Identify core business logic and domain models
- Map data validation rules
- Note important algorithms or complex logic
Adjust these splits based on what the repo actually contains. A frontend-only repo doesn't need a backend agent. A CLI tool doesn't need a frontend agent. Use your judgment.
**Each agent should return:**
- A structured summary of what it found
- File paths to the most important source files
- A suggested list of documentation topics for its area
### Phase 2: Architecture Synthesis
After all discovery agents return, synthesize their findings:
1. **Draw the dependency map** — What are the major components? How do they connect?
2. **Identify documentation topics** — Each distinct service, feature, integration, or subsystem gets its own doc file
3. **Design the directory structure** — Mirror the codebase's architecture. Example:
```
documentation/
├── TABLEOFCONTENTS.md
├── README.md # Project overview (brief)
├── architecture.md # System architecture, tech stack, data flow
├── backend/
│ ├── api-endpoints.md # Or split by domain: users.md, orders.md, etc.
│ ├── database.md # Schema, ORM, migrations
│ ├── auth.md # Authentication & authorization
│ └── jobs.md # Background processing
├── frontend/
│ ├── components.md # Component tree, shared components
│ ├── routing.md # Pages, navigation, guards
│ └── state.md # State management
├── integrations/
│ ├── [service-name].md # One per external integration
│ └── ...
└── deployment/
└── docker.md # Or whatever the deploy mechanism is
```
4. **Prioritize** — Rank topics by impact. High-impact = core architecture, APIs, database schema, auth, and anything with complex logic or non-obvious behavior. Low-impact = static config files, simple utility functions, standard boilerplate.
### Phase 3: Documentation Generation (Delegate to Writer Agents)
Launch **parallel writer agents** using the Task tool. Each agent writes 2-5 related documentation files.
**Instructions for each writer agent must include:**
- The exact file paths to create
- The list of source files to read for that topic
- The token-efficient format template (copy Section 2.1 into each agent's prompt)
- A reminder: "Write concise bullets, not prose. Include all technical details. Target 30-80 lines per file."
**Suggested batching:**
- Agent A: `architecture.md` + `README.md` (needs broadest context)
- Agent B: Backend services docs (group related services)
- Agent C: Frontend docs
- Agent D: Integration docs
- Agent E: Database + deployment docs
Scale the number of agents to the size of the repo. A small repo might need 2-3 writers. A large monorepo might need 8-10.
**Each writer agent should return:** Confirmation of files written, with a brief summary of what each file covers and a list of cross-references to note for the TOC.
### Phase 4: Build the TABLEOFCONTENTS.md
After all writers finish, build the table of contents yourself. This requires you to:
1. Read or review every documentation file that was created
2. For each file, generate 2-5 natural-language questions it answers
3. Organize questions into categories that match the codebase's domain
4. Write the Architecture Overview section (3-10 bullets, high-level only)
5. Cross-check: every doc file appears in at least one row; no dead links
### Phase 5: Generate the CLAUDE.md
Write the project-root `CLAUDE.md` using the template in Section 5 below. Customize it for this specific repo — fill in the actual project name, the actual documentation structure, and real examples from the actual TOC.
### Phase 6: Validate
Do a final pass:
1. Verify every file referenced in TABLEOFCONTENTS.md actually exists
2. Verify every file in the `documentation/` directory appears in TABLEOFCONTENTS.md
3. Spot-check 2-3 doc files for format compliance (status line, bullets not prose, within line limits)
4. Verify CLAUDE.md references the correct paths
---
## 5. CLAUDE.md Template
Generate a `CLAUDE.md` at the project root using this template. **Customize every bracketed item** for the specific repo. Remove sections that don't apply. Keep it under 200 lines — this file is loaded into every conversation and consumes tokens.
```markdown
# CLAUDE.md — [Project Name]
## Documentation System
This project uses a cascading, token-efficient documentation system optimized for AI agent consumption.
### How to Find Information
1. **Read `documentation/TABLEOFCONTENTS.md` FIRST** — this is the navigation index
2. Find your topic in the question-to-file mapping tables
3. Read ONLY the 1-3 files relevant to your task
4. **Never read all documentation files** — this wastes token budget
### Documentation Structure
[Insert the actual directory tree of documentation/ here]
### Example Lookups
- "[Example question 1]" → `[actual-path-1.md]`
- "[Example question 2]" → `[actual-path-2.md]`, `[actual-path-3.md]`
- "[Example question 3]" → `[actual-path-4.md]`
## Token Budget Rules
- **20-30% of tokens:** Reading documentation (via TABLEOFCONTENTS.md targeting)
- **70-80% of tokens:** Implementation and problem-solving
**Do:**
- Use TABLEOFCONTENTS.md to target specific files
- Read only "Key Details" and "API/Interfaces" sections
- Skip code examples unless implementing similar functionality
**Don't:**
- Read all documentation files sequentially
- Read verbose examples when not needed
- Re-read the same docs multiple times in one session
## Documentation Maintenance
When you modify code that changes behavior documented in `documentation/`:
1. Read TABLEOFCONTENTS.md to find the relevant doc(s)
2. Update those docs to reflect your changes
3. Use the token-efficient format: bullets, tables, compact code blocks — no prose
4. If you create a new doc, add it to TABLEOFCONTENTS.md
### Token-Efficient Format Reference
- **Status line:** `**Status:** [Implemented | Partial | Planned] — [one-line summary]`
- **Bullets, not paragraphs** — every detail as a dash-prefixed list item
- **Tables for APIs** — method, path, request, response
- **Code blocks only for schemas/configs** — max 2 per document
- **30-80 lines per file** — split if over 120
- **No:** prose explanations, future plans, testing strategy, empty sections
```
---
## 6. Quality Standards
Your output will be evaluated on:
1. **TABLEOFCONTENTS.md completeness** — Can an agent find any topic by searching this one file?
2. **Question quality** — Are the TOC questions phrased the way someone would actually ask them?
3. **Format compliance** — Do all docs follow the token-efficient format? No prose, no fluff?
4. **Accuracy** — Do the docs match what's actually in the code? Are file paths correct?
5. **Coverage** — Are all high-impact areas documented? Are low-impact areas at least listed?
6. **CLAUDE.md clarity** — Could a brand-new agent read CLAUDE.md and immediately know how to navigate the docs?
7. **Cross-referencing** — Do Related sections link to the right companion docs?
---
## 7. Important Reminders
- **You are writing for AI agents, not humans.** Optimize for parseability and token efficiency, not readability or visual appeal.
- **Accuracy over completeness.** It's better to document 80% of the codebase accurately than 100% with errors. If a discovery agent can't determine something with confidence, note it as `**Status:** Partial` and move on.
- **Mirror the codebase's language.** Use the same names for things that the code uses. If the code calls it a "processor," don't call it a "handler" in the docs.
- **File paths are critical.** Every doc should reference the actual source files it describes. Agents will use these paths to navigate directly to code.
- **The TOC is the product.** The individual doc files are supporting material. If the TOC is excellent, the whole system works. If the TOC is poor, nothing else matters.
- **Delegate aggressively.** You have access to the Task tool with sub-agents. Use it. The discovery phase should be 3-5 parallel agents. The writing phase should be 2-10 parallel agents depending on repo size. Your job is to orchestrate, synthesize, and build the TOC — not to read every file yourself.
- **Do not add headers or comments to source code files.** Your output is documentation files only. Do not modify any existing source code.
---
## Now Begin
Start with Phase 1. Launch your discovery agents in parallel. Once they report back, proceed through the remaining phases. When complete, report what you've created and provide the full TABLEOFCONTENTS.md for review.
+12
View File
@@ -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)
+87
View File
@@ -0,0 +1,87 @@
# Manual Import Feature — Acceptance Criteria
**Status:** ⏳ In Progress
## Overview
Allow admins to manually import audiobook files from the server filesystem into RMAB's processing pipeline for a specific book.
## Acceptance Criteria
### AC-1: Manual Import Button (Frontend)
- [ ] "Manual Import" button visible on `AudiobookDetailsModal` for admin users only
- [ ] Button hidden when book is in active processing states: `downloading`, `processing`, `searching`
- [ ] Button uses `FolderArrowDownIcon` from Heroicons
- [ ] Clicking opens the file browser modal
### AC-2: File Browser Modal — Phase 1 (Browse)
- [ ] Modal opens at `max-w-2xl`, rounded-2xl, with header/breadcrumb/listing/footer regions
- [ ] Root view shows two entry tiles: Downloads and Media Library (paths from `download_dir` and `media_dir` config)
- [ ] Each folder row shows: folder icon, name, metadata line (audio file count, subfolder count, total size)
- [ ] Blue `♪ N` badge on folders containing audio files
- [ ] Folder icon swaps to `FolderOpenIcon` on hover (150ms transition)
- [ ] Single-click selects folder (only if it has audio files); double-click navigates into it
- [ ] Folders without audio files shown at reduced opacity, still navigable but not selectable
- [ ] Breadcrumb navigation with clickable segments, home icon for root, ellipsis collapse for deep paths
- [ ] Footer shows selected path (monospace), file stats, "Review Import →" button (only when valid selection)
- [ ] Directional slide animations: right when going deeper, left when going back
- [ ] Loading skeletons during directory fetch
- [ ] Empty state for empty directories
- [ ] Error state with "Try Again" for failed directory reads
- [ ] Dark mode support throughout
### AC-3: File Browser Modal — Phase 2 (Confirm)
- [ ] Slide transition from browse to confirm phase
- [ ] Shows book context: cover thumbnail + title + author
- [ ] Shows selected folder: path (monospace) + stats in inset block
- [ ] Numbered "What will happen" list: (1) copy to media library, (2) tag metadata, (3) download cover art, (4) scan library
- [ ] "Back" button returns to browse phase
- [ ] "Start Import" primary button triggers the import
- [ ] Button shows loading state during API call
- [ ] Success: close modal, show success toast, trigger request list refresh
- [ ] Error: show error toast, stay on confirm screen
### AC-4: Filesystem Browse API
- [ ] `GET /api/admin/filesystem/browse?path=...` — admin-only endpoint
- [ ] Returns directory listing: `{ entries: [{ name, type, audioFileCount, subfolderCount, totalSize }] }`
- [ ] If no `path` param, returns root directories (download_dir, media_dir from config)
- [ ] Path validation: must be within allowed root directories (prevent directory traversal)
- [ ] Handles permission errors gracefully
- [ ] Sorts: folders first, then alphabetical
### AC-5: Manual Import API
- [ ] `POST /api/admin/manual-import` — admin-only endpoint
- [ ] Request body: `{ audiobookId: string, folderPath: string }`
- [ ] Path validation: folderPath must be within allowed roots
- [ ] Validates folder exists and contains audio files
- [ ] If no existing request: creates request (status: `processing`) + queues `organize_files` job
- [ ] If existing request (non-active state): updates status to `processing` + queues `organize_files` job
- [ ] Returns: `{ success: true, requestId: string }`
- [ ] Proper error responses for: invalid path, no audio files, already processing, book not found
### AC-6: Integration with Existing Pipeline
- [ ] The `organize_files` job processes the manual import folder identically to download-client-delivered folders
- [ ] Files are copied (not moved) to the media library
- [ ] Metadata tagging, cover art download, file hash generation all work as normal
- [ ] Library scan triggered after organization (if configured)
- [ ] Request status progresses: processing → downloaded → available (via scheduled scan)
### AC-7: Docker Build
- [ ] `docker compose build readmeabook` succeeds with no errors
## Non-Goals
- No "move" option (copy only, matching existing pipeline)
- No file-level selection (folder only)
- No drag-and-drop upload
- No non-admin access
## Technical Notes
- Audio extensions: `.m4b`, `.m4a`, `.mp3`, `.mp4`, `.aa`, `.aax`, `.flac`, `.ogg` (from `src/lib/constants/audio-formats.ts`)
- Config keys: `download_dir` (database), `media_dir` (database)
- Existing file organizer: `src/lib/utils/file-organizer.ts`
- Organize processor: `src/lib/processors/organize-files.processor.ts`
- Job queue service: `src/lib/services/job-queue.service.ts`
- Auth middleware: `requireAuth()`, `requireAdmin()` from `src/lib/middleware/auth.ts`
- Frontend API pattern: `fetchWithAuth()` from `src/lib/utils/api.ts`
- Modal base: `src/components/ui/Modal.tsx`
- Audiobook details modal: `src/components/audiobooks/AudiobookDetailsModal.tsx`
- Toast: `useToast()` from toast context
+1
View File
@@ -33,6 +33,7 @@ Configurable Audible region for accurate metadata matching across different inte
- India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English)
- Spain (`es`) - `audible.es` (non-English)
- French (`fr`) - `audible.fr` (non-English)
**`isEnglish` Flag:**
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
+1 -1
View File
@@ -271,7 +271,7 @@ src/app/admin/settings/
**PUT /api/admin/settings/audible**
- Updates Audible region
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es)
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es, fr)
- No validation required
**PUT /api/admin/settings/prowlarr/indexers**
+816 -27
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.0.11",
"version": "1.0.16",
"private": true,
"scripts": {
"dev": "next dev",
@@ -18,7 +18,9 @@
"dependencies": {
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
"@types/archiver": "^7.0.0",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
@@ -43,9 +45,9 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@types/adm-zip": "^0.5.6",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/adm-zip": "^0.5.6",
"@types/bcrypt": "^5.0.2",
"@types/bull": "^4.10.0",
"@types/jsonwebtoken": "^9.0.6",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "download_access" BOOLEAN;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "requests" ADD COLUMN "custom_search_terms" TEXT;
@@ -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;
+166 -14
View File
@@ -55,6 +55,7 @@ model User {
// Fine-grained permissions
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
// Soft delete support
deletedAt DateTime? @map("deleted_at")
@@ -65,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])
@@ -231,6 +237,7 @@ model Request {
importAttempts Int @default(0) @map("import_attempts")
maxImportRetries Int @default(5) @map("max_import_retries")
lastSearchAt DateTime? @map("last_search_at")
customSearchTerms String? @map("custom_search_terms") @db.Text
lastImportAt DateTime? @map("last_import_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -390,7 +397,7 @@ model ScheduledJob {
model BookDateConfig {
id String @id @default(uuid())
provider String // 'openai' | 'claude' | 'custom'
provider String // 'openai' | 'claude' | 'gemini' | 'custom'
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
@@ -494,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")
@@ -513,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

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-9 group-hover:rotate-12 transition-all duration-300" fill="none" viewBox="0 0 40 40"><path d="M12.8889 32.5982C12.666 31.7661 13.1598 30.9108 13.9919 30.6879L30.2971 26.3189C31.1292 26.096 31.9845 26.5898 32.2075 27.4219L32.8739 29.9089C33.1711 31.0183 32.5127 32.1587 31.4033 32.456L18.1113 36.0176C15.8924 36.6121 13.6116 35.2953 13.0171 33.0764L12.8889 32.5982Z" fill="#4F46E5"></path><path d="M7.62314 12.946C7.05137 10.8121 8.3177 8.61876 10.4516 8.04699L16.8851 32.0571L13.0214 33.0924L7.62314 12.946Z" fill="#4F46E5"></path><path d="M29.3358 24.432L31.2677 23.9144L32.3584 27.985C32.6443 29.052 32.0111 30.1486 30.9442 30.4345L29.3358 24.432Z" fill="#4338CA"></path><path d="M26.4446 5.91475C26.1474 4.80529 25.007 4.14688 23.8975 4.44416L10.5286 8.02636C9.41911 8.32364 8.7607 9.46403 9.05798 10.5735L14.9532 32.5748L22.6461 30.5135C23.1986 30.3654 23.5265 29.7975 23.3785 29.245C23.2304 28.6925 23.5583 28.1245 24.1108 27.9765L29.7949 26.4535C30.9043 26.1562 31.5628 25.0158 31.2655 23.9063L26.4446 5.91475Z" fill="#6366F1"></path><path d="M21.0947 11.2811C21.145 10.6645 21.9408 10.4512 22.2927 10.9601L22.442 11.1761C22.5512 11.3341 22.724 11.4365 22.9151 11.4565L23.2375 11.4902C23.838 11.553 24.0445 12.3235 23.5558 12.6781L23.2935 12.8685C23.138 12.9813 23.0395 13.1564 23.0239 13.3479L23.0026 13.6096C22.9523 14.2262 22.1564 14.4394 21.8046 13.9306L21.6553 13.7146C21.546 13.5566 21.3732 13.4542 21.1821 13.4342L20.8598 13.4005C20.2592 13.3377 20.0528 12.5672 20.5415 12.2126L20.8038 12.0222C20.9593 11.9094 21.0577 11.7343 21.0734 11.5428L21.0947 11.2811Z" fill="#312E81"></path><path d="M18.3031 16.3181C18.3533 15.7015 19.1492 15.4882 19.501 15.9971L20.5634 17.5337C20.6727 17.6917 20.8455 17.7941 21.0366 17.8141L22.9139 18.0104C23.5144 18.0732 23.7208 18.8436 23.2321 19.1983L21.7045 20.3069C21.549 20.4197 21.4506 20.5949 21.435 20.7863L21.2832 22.6482C21.2329 23.2649 20.4371 23.4781 20.0852 22.9692L19.0228 21.4327C18.9136 21.2747 18.7407 21.1722 18.5497 21.1522L16.6724 20.956C16.0719 20.8932 15.8654 20.1227 16.3541 19.7681L17.8817 18.6594C18.0372 18.5466 18.1357 18.3715 18.1513 18.18L18.3031 16.3181Z" fill="#312E81"></path><path d="M14.9532 32.5748C14.6571 31.4697 15.3129 30.3339 16.4179 30.0378L29.8719 26.4328L30.9441 30.4345L17.4902 34.0395C16.3851 34.3356 15.2493 33.6798 14.9532 32.5748Z" fill="#EEF2FF"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,154 @@
/**
* Component: Adjust Search Terms Modal
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast';
interface AdjustSearchTermsModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
title: string;
author: string;
currentSearchTerms?: string | null;
onSuccess?: () => void;
}
export function AdjustSearchTermsModal({
isOpen,
onClose,
requestId,
title,
author,
currentSearchTerms,
onSuccess,
}: AdjustSearchTermsModalProps) {
const toast = useToast();
const [searchTerms, setSearchTerms] = useState(currentSearchTerms || title);
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndSearching, setIsSavingAndSearching] = useState(false);
// Reset state when modal opens
const handleClose = () => {
setSearchTerms(currentSearchTerms || title);
onClose();
};
const save = async (triggerSearch: boolean) => {
const setter = triggerSearch ? setIsSavingAndSearching : setIsSaving;
setter(true);
try {
// If terms match the original title, clear the override
const termsToSave = searchTerms.trim() === title ? null : searchTerms.trim() || null;
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/search-terms`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ searchTerms: termsToSave, triggerSearch }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update search terms');
}
const data = await response.json();
if (data.searchTriggered) {
toast.success('Search terms saved and search triggered');
} else {
toast.success('Search terms saved');
}
onSuccess?.();
onClose();
} catch (error) {
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setter(false);
}
};
const handleReset = () => {
setSearchTerms(title);
};
const isLoading = isSaving || isSavingAndSearching;
const hasChanges = searchTerms.trim() !== (currentSearchTerms || title);
const isCustom = searchTerms.trim() !== title;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Adjust Search Terms" size="sm">
<div className="space-y-4">
{/* Original info */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 space-y-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Original Title
</div>
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">{title}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">by {author}</div>
</div>
{/* Search terms input */}
<div>
<label
htmlFor="search-terms"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
>
Search Terms
</label>
<input
id="search-terms"
type="text"
value={searchTerms}
onChange={(e) => setSearchTerms(e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="Enter custom search terms..."
/>
{isCustom && (
<button
onClick={handleReset}
disabled={isLoading}
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-50"
>
Reset to original title
</button>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleClose}
disabled={isLoading}
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-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => save(false)}
disabled={isLoading || !searchTerms.trim()}
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-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => save(true)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{isSavingAndSearching ? 'Saving...' : 'Save & Search'}
</button>
</div>
</div>
</Modal>
);
}
+153 -74
View File
@@ -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>
@@ -28,6 +28,8 @@ interface RecentRequest {
completedAt: Date | null;
errorMessage: string | null;
torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
}
interface User {
@@ -444,6 +446,29 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
}
};
const handleRetryDownload = async (requestId: string) => {
try {
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/retry-download`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(responseData.message || 'Failed to retry download');
}
toast.success(responseData.message || 'Download retry initiated');
await mutate(apiUrl);
} catch (error) {
console.error('[Admin] Failed to retry download:', error);
toast.error(`Failed to retry download: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
// Render loading state
if (isLoading && !data) {
return (
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
Ebook
</span>
)}
{request.customSearchTerms && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200"
title={`Custom search: ${request.customSearchTerms}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Custom Search
</span>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
type: request.type,
asin: request.asin,
torrentUrl: request.torrentUrl,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms,
}}
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
onRetryDownload={handleRetryDownload}
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
onFetchEbook={handleFetchEbook}
onSearchTermsUpdated={() => mutate(apiUrl)}
ebookSidecarEnabled={ebookSidecarEnabled}
annasArchiveBaseUrl={annasArchiveBaseUrl}
isLoading={isDeleting || isFetchingEbook}
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
}}
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
requestStatus={viewDetailsStatus}
hideRequestActions
/>
)}
</div>
@@ -10,6 +10,7 @@
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export interface RequestActionsDropdownProps {
@@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps {
type?: 'audiobook' | 'ebook';
asin?: string | null;
torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
};
onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => Promise<void>;
onCancel: (requestId: string) => Promise<void>;
onRetryDownload?: (requestId: string) => Promise<void>;
onViewDetails?: (asin: string) => void;
onFetchEbook?: (requestId: string) => Promise<void>;
onSearchTermsUpdated?: () => void;
ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string;
isLoading?: boolean;
@@ -37,8 +42,10 @@ export function RequestActionsDropdown({
onDelete,
onManualSearch,
onCancel,
onRetryDownload,
onViewDetails,
onFetchEbook,
onSearchTermsUpdated,
ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li',
isLoading = false,
@@ -46,6 +53,7 @@ export function RequestActionsDropdown({
const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
// Determine request type
@@ -54,9 +62,10 @@ 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);
// 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
@@ -120,7 +129,16 @@ export function RequestActionsDropdown({
const handleInteractiveSearch = () => {
setIsOpen(false);
setShowInteractiveSearch(true);
if (isEbook) {
setShowInteractiveSearchEbook(true);
} else {
setShowInteractiveSearch(true);
}
};
const handleAdjustSearchTerms = () => {
setIsOpen(false);
setShowAdjustSearchTerms(true);
};
const handleInteractiveSearchEbook = () => {
@@ -128,6 +146,17 @@ export function RequestActionsDropdown({
setShowInteractiveSearchEbook(true);
};
const handleRetryDownload = async () => {
setIsOpen(false);
if (onRetryDownload) {
try {
await onRetryDownload(request.requestId);
} catch (error) {
console.error('Failed to retry download:', error);
}
}
};
const handleCancel = async () => {
setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
@@ -253,6 +282,35 @@ export function RequestActionsDropdown({
</button>
)}
{/* Adjust Search Terms */}
{canAdjustSearchTerms && (
<button
onClick={handleAdjustSearchTerms}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span className="flex items-center gap-1.5">
Adjust Search Terms
{request.customSearchTerms && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
)}
</span>
</button>
)}
{/* View Source */}
{canViewSource && viewSourceUrl && (
<a
@@ -328,8 +386,32 @@ export function RequestActionsDropdown({
</button>
)}
{/* Divider if we have search/view actions and other actions */}
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
{/* Retry Download */}
{canRetryDownload && (
<button
onClick={handleRetryDownload}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Retry Download
</button>
)}
{/* Divider if we have search/view/retry actions and other actions */}
{(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
@@ -358,7 +440,7 @@ export function RequestActionsDropdown({
)}
{/* Divider before delete */}
{canDelete && (canSearch || canCancel) && (
{canDelete && (canSearch || canRetryDownload || canCancel) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
@@ -421,6 +503,7 @@ export function RequestActionsDropdown({
title: request.title,
author: request.author,
}}
customSearchTerms={request.customSearchTerms}
/>
{/* Interactive Search Modal (Ebook) */}
@@ -433,6 +516,18 @@ export function RequestActionsDropdown({
author: request.author,
}}
searchMode="ebook"
customSearchTerms={request.customSearchTerms}
/>
{/* Adjust Search Terms Modal */}
<AdjustSearchTermsModal
isOpen={showAdjustSearchTerms}
onClose={() => setShowAdjustSearchTerms(false)}
requestId={request.requestId}
title={request.title}
author={request.author}
currentSearchTerms={request.customSearchTerms}
onSuccess={onSearchTermsUpdated}
/>
</>
);
+157 -70
View File
@@ -13,17 +13,34 @@ import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
interface SelectedTorrentData {
title?: string;
indexer?: string;
size?: number;
format?: string;
ebookFormat?: string;
seeders?: number;
infoUrl?: string;
source?: string;
protocol?: string;
score?: number;
}
interface PendingApprovalRequest {
id: string;
createdAt: string;
type: 'audiobook' | 'ebook';
selectedTorrent: SelectedTorrentData | null;
audiobook: {
title: string;
author: string;
coverArtUrl: string | null;
audibleAsin: string | null;
};
user: {
id: string;
@@ -32,9 +49,20 @@ interface PendingApprovalRequest {
};
}
function formatTorrentSize(bytes: number): string {
const gb = bytes / (1024 ** 3);
const mb = bytes / (1024 ** 2);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
}
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
const searchModalRequest = searchModalRequestId
? requests.find((r) => r.id === searchModalRequestId)
: null;
const handleApproveRequest = async (requestId: string) => {
setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
@@ -47,7 +75,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
toast.success('Request approved');
// Mutate both pending requests and recent requests caches
await mutate('/api/admin/requests/pending-approval');
await mutate('/api/admin/requests/recent');
await mutate('/api/admin/metrics');
@@ -72,7 +99,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
toast.success('Request denied');
// Mutate pending requests cache
await mutate('/api/admin/requests/pending-approval');
await mutate('/api/admin/metrics');
} catch (error) {
@@ -85,6 +111,26 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
}
};
const handleApproveWithTorrent = async (requestId: string, torrent: TorrentResult) => {
await fetchJSON(`/api/admin/requests/${requestId}/approve`, {
method: 'POST',
body: JSON.stringify({ action: 'approve', selectedTorrent: torrent }),
});
toast.success('Request approved and download started');
await mutate('/api/admin/requests/pending-approval');
await mutate('/api/admin/requests/recent');
await mutate('/api/admin/metrics');
};
const LoadingSpinner = () => (
<svg className="animate-spin h-4 w-4" 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>
);
return (
<div className="mb-8">
{/* Section Header */}
@@ -116,6 +162,9 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{requests.map((request) => {
const isLoading = loadingStates[request.id] || false;
const torrent = request.selectedTorrent;
const displayFormat = torrent?.format || torrent?.ebookFormat;
const isAnnasArchive = torrent?.source === 'annas_archive';
return (
<div
@@ -205,89 +254,107 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
</div>
</div>
{/* Pre-Selected Release */}
{torrent && torrent.title && (
<div className="mx-4 mb-3 px-3 py-2.5 bg-gray-50 dark:bg-gray-900/60 rounded-lg border border-gray-200 dark:border-gray-700/60">
<div className="flex items-center gap-1.5 mb-1">
<svg className="w-3 h-3 text-gray-400 dark:text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
User-Selected Release
</span>
</div>
{torrent.infoUrl ? (
<a
href={torrent.infoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors line-clamp-2 leading-snug"
title={torrent.title}
>
{torrent.title}
</a>
) : (
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 line-clamp-2 leading-snug" title={torrent.title}>
{torrent.title}
</p>
)}
<div className="flex items-center gap-1 mt-1.5 text-[11px] text-gray-500 dark:text-gray-400 flex-wrap">
{isAnnasArchive ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna&apos;s Archive</span>
) : torrent.indexer ? (
<span>{torrent.indexer}</span>
) : null}
{torrent.size && torrent.size > 0 ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatTorrentSize(torrent.size)}</span>
</>
) : null}
{displayFormat ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
{displayFormat}
</span>
</>
) : null}
{torrent.protocol === 'usenet' ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-sky-600 dark:text-sky-400 font-medium">NZB</span>
</>
) : torrent.seeders !== undefined && torrent.seeders !== null ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-emerald-600 dark:text-emerald-400">{torrent.seeders} seeds</span>
</>
) : null}
{torrent.score !== undefined && torrent.score !== null ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="font-medium">Score {Math.round(torrent.score)}</span>
</>
) : null}
</div>
</div>
)}
{/* Action Buttons */}
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
<button
onClick={() => handleApproveRequest(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? (
<svg
className="animate-spin h-4 w-4"
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>
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span>Approve</span>
</button>
<button
onClick={() => setSearchModalRequestId(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Search</span>
</button>
<button
onClick={() => handleDenyRequest(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? (
<svg
className="animate-spin h-4 w-4"
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>
) : (
<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"
/>
{isLoading ? <LoadingSpinner /> : (
<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>
)}
<span>Deny</span>
@@ -297,6 +364,26 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
);
})}
</div>
{/* Interactive Search Modal */}
{searchModalRequest && (
<InteractiveTorrentSearchModal
isOpen={!!searchModalRequestId}
onClose={() => setSearchModalRequestId(null)}
requestId={searchModalRequest.id}
audiobook={{
title: searchModalRequest.audiobook.title,
author: searchModalRequest.audiobook.author,
}}
searchMode={searchModalRequest.type === 'ebook' ? 'ebook' : 'audiobook'}
onConfirm={async (torrent) => {
await handleApproveWithTorrent(searchModalRequest.id, torrent);
}}
onSuccess={() => {
setSearchModalRequestId(null);
}}
/>
)}
</div>
);
}
+2
View File
@@ -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: '🔑' },
];
+3 -1
View File
@@ -100,6 +100,8 @@ export interface PathsSettings {
ebookPathTemplate?: string;
metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean;
fileRenameEnabled: boolean;
fileRenameTemplate?: string;
}
/**
@@ -241,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';
+5 -1
View File
@@ -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&apos;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&apos;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">
&ldquo;{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}&rdquo;
</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>
);
}
@@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
>
<option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
<option value="custom">Custom (OpenAI-compatible)</option>
</select>
</div>
@@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
? 'Leave blank for local models'
: configured
? '••••••••••••••••'
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...')
: (provider === 'openai' ? 'sk-...' : provider === 'gemini' ? 'AIza...' : 'sk-ant-...')
}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { usePathsSettings } from './usePathsSettings';
import type { PathsSettings } from '../../lib/types';
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
import { validateTemplate, generateMockPreviews, validateFilenameTemplate, generateMockFilenamePreviews } from '@/lib/utils/path-template.util';
interface PathsTabProps {
paths: PathsSettings;
@@ -24,6 +24,13 @@ interface TemplatePreview {
previewPaths?: string[];
}
interface FilenamePreview {
isValid: boolean;
error?: string;
single?: string[];
multi?: string[];
}
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
paths,
@@ -73,6 +80,34 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
}
}, [paths.ebookPathTemplate]);
// Live preview state for filename template
const [filenamePreview, setFilenamePreview] = useState<FilenamePreview | null>(null);
// Update filename live preview whenever template changes
useEffect(() => {
if (!paths.fileRenameEnabled) {
setFilenamePreview(null);
return;
}
const template = paths.fileRenameTemplate || '{title}';
const validation = validateFilenameTemplate(template);
if (validation.valid) {
const previews = generateMockFilenamePreviews(template);
setFilenamePreview({
isValid: true,
single: previews.single,
multi: previews.multi,
});
} else {
setFilenamePreview({
isValid: false,
error: validation.error,
});
}
}, [paths.fileRenameTemplate, paths.fileRenameEnabled]);
const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
@@ -218,6 +253,83 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
)}
</div>
{/* File Rename Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="file-rename-settings"
checked={paths.fileRenameEnabled}
onChange={(e) => updatePath('fileRenameEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="file-rename-settings"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Rename files during organization
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Rename audio and ebook files using a custom naming template when organizing into the media
library. When multiple files exist (e.g. chapterized MP3s), an index number is appended.
</p>
</div>
</div>
{/* File Naming Template (shown when enabled) */}
{paths.fileRenameEnabled && (
<div className="mt-4 pl-9">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
File Naming Template
</label>
<Input
type="text"
value={paths.fileRenameTemplate || '{title}'}
onChange={(e) => updatePath('fileRenameTemplate', e.target.value)}
placeholder="{title}"
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Uses the same variables as the organization template. Do not include the file extension.
</p>
{/* Filename Validation Error */}
{filenamePreview && !filenamePreview.isValid && (
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
<span className="flex-shrink-0 mt-0.5"></span>
<div className="flex-1">
<span>{filenamePreview.error || 'Invalid filename template'}</span>
</div>
</div>
)}
{/* Filename Preview */}
{filenamePreview && filenamePreview.isValid && (
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Single File
</h4>
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{filenamePreview.single?.map((preview, index) => (
<div key={index} className="text-xs">{preview}</div>
))}
</div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-3 mb-2">
Multiple Files (chapterized)
</h4>
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{filenamePreview.multi?.map((preview, index) => (
<div key={index} className="text-xs">{preview}</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Variable Reference Panel (shared for both templates) */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
@@ -255,6 +367,27 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
</div>
</div>
{/* Conditional Syntax Help */}
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2">
Conditional Syntax
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Wrap text around a variable in <code className="text-amber-700 dark:text-amber-300 font-mono">{'{ }'}</code> to
include that text only when the variable has a value. If the variable is empty, the entire block is removed.
</p>
<div className="text-sm font-mono bg-white dark:bg-gray-900 rounded px-3 py-2 border border-amber-100 dark:border-amber-900">
<div className="text-gray-700 dark:text-gray-300">
<code className="text-amber-700 dark:text-amber-300">{'{Book seriesPart - }'}</code>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
With value: <span className="text-green-700 dark:text-green-400">Book 1 - </span>
&nbsp;&bull;&nbsp;
Without value: <span className="text-red-700 dark:text-red-400">(removed)</span>
</div>
</div>
</div>
{/* Metadata Tagging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
+58
View File
@@ -28,6 +28,7 @@ interface User {
lastLoginAt: string | null;
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
_count: {
requests: number;
};
@@ -193,6 +194,10 @@ function AdminUsersPageContent() {
'/api/admin/settings/interactive-search',
authenticatedFetcher
);
const { data: globalDownloadAccessData, mutate: mutateGlobalDownloadAccess } = useSWR(
'/api/admin/settings/download-access',
authenticatedFetcher
);
const [editDialog, setEditDialog] = useState<{
isOpen: boolean;
user: User | null;
@@ -212,6 +217,7 @@ function AdminUsersPageContent() {
const [deleting, setDeleting] = useState(false);
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
const toast = useToast();
@@ -237,6 +243,15 @@ function AdminUsersPageContent() {
}
}, [globalInteractiveSearchData]);
// Sync global download access state (default to true if not set)
useEffect(() => {
if (globalDownloadAccessData?.downloadAccess !== undefined) {
setGlobalDownloadAccess(globalDownloadAccessData.downloadAccess);
} else if (globalDownloadAccessData !== undefined && globalDownloadAccessData.downloadAccess === undefined) {
setGlobalDownloadAccess(true);
}
}, [globalDownloadAccessData]);
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
setGlobalAutoApprove(newValue);
try {
@@ -311,6 +326,43 @@ function AdminUsersPageContent() {
}
};
const handleGlobalDownloadAccessToggle = async (newValue: boolean) => {
setGlobalDownloadAccess(newValue);
try {
await fetchJSON('/api/admin/settings/download-access', {
method: 'PATCH',
body: JSON.stringify({ downloadAccess: newValue }),
});
toast.success(`Global download access ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalDownloadAccess();
mutate();
} catch (err) {
setGlobalDownloadAccess(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update download access setting';
toast.error(errorMsg);
}
};
const handleUserDownloadAccessToggle = async (user: User, newValue: boolean) => {
const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, downloadAccess: newValue } : u
);
mutate({ users: optimisticUsers }, false);
try {
await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify({ role: user.role, downloadAccess: newValue }),
});
toast.success(`Download access ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
mutate();
} catch (err) {
mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user download access setting';
toast.error(errorMsg);
}
};
const showEditDialog = (user: User) => {
setEditRole(user.role);
setEditDialog({ isOpen: true, user });
@@ -909,6 +961,8 @@ function AdminUsersPageContent() {
onToggleAutoApprove={handleGlobalAutoApproveToggle}
globalInteractiveSearch={globalInteractiveSearch}
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
globalDownloadAccess={globalDownloadAccess}
onToggleDownloadAccess={handleGlobalDownloadAccessToggle}
/>
{/* User Permissions Modal */}
@@ -918,12 +972,16 @@ function AdminUsersPageContent() {
user={permissionsUser}
globalAutoApprove={globalAutoApprove}
globalInteractiveSearch={globalInteractiveSearch}
globalDownloadAccess={globalDownloadAccess}
onToggleAutoApprove={(user, newValue) => {
handleUserAutoApproveToggle(user as User, newValue);
}}
onToggleInteractiveSearch={(user, newValue) => {
handleUserInteractiveSearchToggle(user as User, newValue);
}}
onToggleDownloadAccess={(user, newValue) => {
handleUserDownloadAccessToggle(user as User, newValue);
}}
/>
</div>
</div>
+142
View File
@@ -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 });
}
})
);
}
+190
View File
@@ -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 });
}
})
);
}
@@ -0,0 +1,208 @@
/**
* Component: Admin Filesystem Browse API
* Documentation: documentation/features/manual-import.md
*
* Lets admins browse server directories for manual audiobook import.
* Restricted to download_dir and media_dir roots only.
*/
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 { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
interface DirectoryEntry {
name: string;
type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
}
/**
* Scan immediate children of a directory to gather audio file and subfolder stats.
*/
async function getDirectoryStats(
dirPath: string
): Promise<{ audioFileCount: number; subfolderCount: number; totalSize: number }> {
const fs = await import('fs/promises');
const pathModule = await import('path');
let audioFileCount = 0;
let subfolderCount = 0;
let totalSize = 0;
try {
const children = await fs.readdir(dirPath, { withFileTypes: true });
for (const child of children) {
if (child.isDirectory()) {
subfolderCount++;
} else if (child.isFile()) {
const ext = pathModule.extname(child.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
audioFileCount++;
try {
const stat = await fs.stat(pathModule.join(dirPath, child.name));
totalSize += stat.size;
} catch {
/* skip unreadable files */
}
}
}
}
} catch {
/* directory not readable */
}
return { audioFileCount, subfolderCount, totalSize };
}
/**
* Load allowed root directories from Configuration table.
*/
const BOOKDROP_PATH = '/bookdrop';
async function getAllowedRoots(): Promise<{ downloadDir: string | null; mediaDir: string | null; bookdropExists: boolean }> {
const downloadDirConfig = await prisma.configuration.findUnique({
where: { key: 'download_dir' },
});
const mediaDirConfig = await prisma.configuration.findUnique({
where: { key: 'media_dir' },
});
let bookdropExists = false;
try {
const fs = await import('fs/promises');
const stat = await fs.stat(BOOKDROP_PATH);
bookdropExists = stat.isDirectory();
} catch {
/* not mounted */
}
return {
downloadDir: downloadDirConfig?.value || null,
mediaDir: mediaDirConfig?.value || null,
bookdropExists,
};
}
/**
* Check if a normalized path is within one of the allowed roots.
*/
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
return roots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
}
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pathModule = await import('path');
const fs = await import('fs/promises');
const { downloadDir, mediaDir, bookdropExists } = await getAllowedRoots();
const requestedPath = request.nextUrl.searchParams.get('path');
// No path param: return root directories
if (!requestedPath) {
const roots: Array<{ name: string; path: string; icon: string }> = [];
if (downloadDir) {
roots.push({ name: 'Downloads', path: downloadDir, icon: 'download' });
}
if (mediaDir) {
roots.push({ name: 'Media Library', path: mediaDir, icon: 'library' });
}
if (bookdropExists) {
roots.push({ name: 'Book Drop', path: BOOKDROP_PATH, icon: 'bookdrop' });
}
if (roots.length === 0) {
return NextResponse.json(
{ error: 'No browsable directories available' },
{ status: 400 }
);
}
return NextResponse.json({ roots });
}
// Path param provided: browse that directory
// Normalize to forward slashes and resolve
const normalizedPath = pathModule.resolve(requestedPath).replace(/\\/g, '/');
// Build list of allowed roots (normalized)
const allowedRoots: string[] = [];
if (downloadDir) allowedRoots.push(pathModule.resolve(downloadDir).replace(/\\/g, '/'));
if (mediaDir) allowedRoots.push(pathModule.resolve(mediaDir).replace(/\\/g, '/'));
if (bookdropExists) allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
if (!isPathAllowed(normalizedPath, allowedRoots)) {
logger.warn(`Access denied: ${normalizedPath} is outside allowed directories`);
return NextResponse.json(
{ error: 'Access denied: path outside allowed directories' },
{ status: 403 }
);
}
// Read directory entries
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
// Gather stats for each subdirectory (parallel for performance)
const directoryEntries = dirEntries.filter((e) => e.isDirectory());
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => {
const fullPath = pathModule.join(normalizedPath, entry.name);
const stats = await getDirectoryStats(fullPath);
return {
name: entry.name,
type: 'directory',
...stats,
};
});
const entries = await Promise.all(statsPromises);
entries.sort((a, b) => a.name.localeCompare(b.name));
// Gather audio files in the current directory
const audioFiles: Array<{ name: string; size: number }> = [];
for (const entry of dirEntries) {
if (entry.isFile()) {
const ext = pathModule.extname(entry.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
try {
const stat = await fs.stat(pathModule.join(normalizedPath, entry.name));
audioFiles.push({ name: entry.name, size: stat.size });
} catch {
audioFiles.push({ name: entry.name, size: 0 });
}
}
}
}
audioFiles.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json({ path: normalizedPath, entries, audioFiles });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
}
if (code === 'EACCES' || code === 'EPERM') {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
logger.error('Failed to browse directory', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to browse directory' },
{ status: 500 }
);
}
});
});
}
+308
View File
@@ -0,0 +1,308 @@
/**
* Component: Admin Manual Import API
* Documentation: documentation/features/manual-import.md
*
* Triggers the organize_files pipeline for a manually-selected folder.
* Creates or recycles a request, then queues the organize job.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { RMABLogger } from '@/lib/utils/logger';
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
import { getAudibleService } from '@/lib/integrations/audible.service';
const logger = RMABLogger.create('API.Admin.ManualImport');
/** Statuses that indicate the request is actively being worked on. */
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
/** Statuses that can be recycled for a new manual import. */
const RECYCLABLE_STATUSES = ['failed', 'warn', 'cancelled', 'denied', 'pending', 'awaiting_search', 'awaiting_approval'];
/**
* Check if a directory contains at least one audio file (immediate children only).
*/
async function hasAudioFiles(dirPath: string): Promise<{ found: boolean; count: number }> {
const fs = await import('fs/promises');
const pathModule = await import('path');
let count = 0;
try {
const children = await fs.readdir(dirPath, { withFileTypes: true });
for (const child of children) {
if (child.isFile()) {
const ext = pathModule.extname(child.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
count++;
}
}
}
} catch {
/* directory not readable */
}
return { found: count > 0, count };
}
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pathModule = await import('path');
const fs = await import('fs/promises');
const body = await request.json();
const { folderPath, asin, cleanupSource } = body;
let { audiobookId } = body;
// Validate required fields
if ((!audiobookId && !asin) || !folderPath) {
return NextResponse.json(
{ error: 'folderPath and either audiobookId or asin are required' },
{ status: 400 }
);
}
// Load allowed roots
const BOOKDROP_PATH = '/bookdrop';
const downloadDirConfig = await prisma.configuration.findUnique({
where: { key: 'download_dir' },
});
const mediaDirConfig = await prisma.configuration.findUnique({
where: { key: 'media_dir' },
});
const allowedRoots: string[] = [];
if (downloadDirConfig?.value) {
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
}
if (mediaDirConfig?.value) {
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
}
try {
const bookdropStat = await fs.stat(BOOKDROP_PATH);
if (bookdropStat.isDirectory()) {
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
}
} catch {
/* not mounted */
}
// Normalize and validate path
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
const isAllowed = allowedRoots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
if (!isAllowed) {
return NextResponse.json(
{ error: 'Access denied: path outside allowed directories' },
{ status: 403 }
);
}
// Verify folder exists and is a directory
try {
const stat = await fs.stat(normalizedPath);
if (!stat.isDirectory()) {
return NextResponse.json(
{ error: 'Path is not a directory' },
{ status: 400 }
);
}
} catch {
return NextResponse.json(
{ error: 'Directory not found' },
{ status: 404 }
);
}
// Verify folder contains audio files
const audioCheck = await hasAudioFiles(normalizedPath);
if (!audioCheck.found) {
return NextResponse.json(
{ error: 'No audio files found in the selected directory' },
{ status: 400 }
);
}
// Resolve audiobook by ASIN if audiobookId not provided
if (!audiobookId && asin) {
const byAsin = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (byAsin) {
audiobookId = byAsin.id;
} else {
// Create audiobook record from Audible cache if available
const cached = await prisma.audibleCache.findUnique({
where: { asin },
});
if (cached) {
const newBook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: cached.title,
author: cached.author,
coverArtUrl: cached.coverArtUrl,
narrator: cached.narrator,
status: 'pending',
},
});
audiobookId = newBook.id;
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
} else {
return NextResponse.json(
{ error: 'Audiobook not found for the given ASIN' },
{ status: 404 }
);
}
}
}
// Verify audiobook exists
const audiobook = await prisma.audiobook.findUnique({
where: { id: audiobookId },
});
if (!audiobook) {
return NextResponse.json(
{ error: 'Audiobook not found' },
{ status: 404 }
);
}
// 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: {
audiobookId,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
let requestId: string;
if (existingRequest) {
// Check if already in an active state
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
return NextResponse.json(
{ error: 'This audiobook is already being processed' },
{ status: 409 }
);
}
// Recycle the existing request
if (RECYCLABLE_STATUSES.includes(existingRequest.status) ||
existingRequest.status === 'downloaded' ||
existingRequest.status === 'available') {
await prisma.request.update({
where: { id: existingRequest.id },
data: {
status: 'processing',
progress: 100,
errorMessage: null,
importAttempts: 0,
updatedAt: new Date(),
},
});
requestId = existingRequest.id;
logger.info(`Recycled existing request ${requestId} for manual import`);
} else {
// Unknown status - create new
const newRequest = await prisma.request.create({
data: {
userId: req.user!.id,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newRequest.id;
logger.info(`Created new request ${requestId} (existing had status: ${existingRequest.status})`);
}
} else {
// No existing request - create one
const newRequest = await prisma.request.create({
data: {
userId: req.user!.id,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newRequest.id;
logger.info(`Created new request ${requestId} for manual import`);
}
// Queue organize_files job
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true);
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
return NextResponse.json({
success: true,
requestId,
message: `Import started for ${audiobook.title}`,
});
} catch (error) {
logger.error('Manual import failed', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Manual import failed' },
{ status: 500 }
);
}
});
});
}
@@ -14,6 +14,7 @@ const logger = RMABLogger.create('API.Admin.Requests.Approve');
const ApprovalActionSchema = z.object({
action: z.enum(['approve', 'deny']),
selectedTorrent: z.any().optional(),
});
/**
@@ -37,8 +38,8 @@ export async function POST(
const { id } = await params;
const body = await request.json();
// Validate action
const { action } = ApprovalActionSchema.parse(body);
// Validate action and optional admin-selected torrent
const { action, selectedTorrent: adminSelectedTorrent } = ApprovalActionSchema.parse(body);
// Fetch the request
const existingRequest = await prisma.request.findUnique({
@@ -78,12 +79,15 @@ export async function POST(
const jobQueue = getJobQueueService();
const isEbookRequest = existingRequest.type === 'ebook';
// Check if request has a pre-selected torrent (from interactive search)
if (existingRequest.selectedTorrent) {
const selectedTorrent = existingRequest.selectedTorrent as any;
// Use admin-provided torrent (from admin interactive search) or fall back to user's pre-selected torrent
const effectiveTorrent = adminSelectedTorrent || existingRequest.selectedTorrent;
// User pre-selected a specific torrent - download that torrent directly
logger.info(`Request ${id} has pre-selected torrent, starting download`, {
if (effectiveTorrent) {
const selectedTorrent = effectiveTorrent as any;
const torrentSource = adminSelectedTorrent ? 'admin' : 'user';
// Download the selected torrent directly
logger.info(`Request ${id} has ${torrentSource}-selected torrent, starting download`, {
requestId: id,
userId: existingRequest.userId,
adminId: req.user.sub,
@@ -167,17 +171,20 @@ export async function POST(
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading pre-selected torrent`, {
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading ${torrentSource}-selected torrent`, {
requestId: id,
userId: updatedRequest.userId,
audiobookTitle: existingRequest.audiobook.title,
adminId: req.user.sub,
type: existingRequest.type,
torrentSource,
});
return NextResponse.json({
success: true,
message: 'Request approved and download started with pre-selected torrent',
message: adminSelectedTorrent
? 'Request approved and download started with admin-selected torrent'
: 'Request approved and download started with pre-selected torrent',
request: updatedRequest,
});
} else {
@@ -0,0 +1,271 @@
/**
* Component: Admin Retry Download API
* Documentation: documentation/admin-dashboard.md
*
* Retries a failed download by either resuming monitoring of a still-alive
* download in the client, or re-adding the download using metadata from the
* most recent selected DownloadHistory record.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Requests.RetryDownload');
/** Download statuses considered "alive" — monitoring can be resumed */
const ALIVE_STATUSES = new Set([
'downloading',
'queued',
'paused',
'checking',
'seeding',
'completed',
]);
/**
* POST /api/admin/requests/[id]/retry-download
* Retry a failed download for an admin request.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
// Fetch the request with audiobook info
const existingRequest = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: {
audiobook: true,
},
});
if (!existingRequest) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
if (existingRequest.status !== 'failed') {
return NextResponse.json(
{
error: 'InvalidStatus',
message: `Request is not in a failed state (current status: ${existingRequest.status})`,
currentStatus: existingRequest.status,
},
{ status: 400 }
);
}
// Find the most recent selected DownloadHistory record
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId: id, selected: true },
orderBy: { createdAt: 'desc' },
});
if (!downloadHistory) {
return NextResponse.json(
{
error: 'NoHistory',
message: 'No previous download attempt found to retry',
},
{ status: 400 }
);
}
// Require a download URL to be able to re-add
if (!downloadHistory.magnetLink) {
return NextResponse.json(
{
error: 'NoDownloadUrl',
message: 'No download URL available in history to retry',
},
{ status: 400 }
);
}
const jobQueue = getJobQueueService();
let retryPath: 'resumed_monitoring' | 're_added';
// Determine if we can attempt to resume monitoring.
// downloadClient is stored as a plain string in the DB (can be 'qbittorrent', 'sabnzbd',
// 'nzbget', 'transmission', 'deluge', 'direct', or null).
const rawClientType: string | null = downloadHistory.downloadClient;
const clientId = downloadHistory.downloadClientId;
const isDirect = rawClientType === 'direct';
// Only attempt to query the download client if we have a known DownloadClientType,
// a clientId, and it is not a direct (HTTP) download.
const canCheckClient = !isDirect && !!rawClientType && !!clientId;
// Safe to cast here: we have already confirmed rawClientType is non-null and non-direct
const clientType = rawClientType as DownloadClientType | null;
if (canCheckClient) {
// Try to look up the download in the client
try {
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const client = await manager.getClientServiceForProtocol(protocol);
if (client) {
const downloadInfo = await client.getDownload(clientId!);
if (downloadInfo && ALIVE_STATUSES.has(downloadInfo.status)) {
// Download is still alive — restart monitoring
logger.info(`Retry download: resuming monitoring for request ${id}`, {
requestId: id,
downloadClientId: clientId,
downloadStatus: downloadInfo.status,
adminId: req.user.sub,
});
await jobQueue.addMonitorJob(
id,
downloadHistory.id,
clientId!, // canCheckClient guard ensures clientId is non-null
clientType as DownloadClientType,
0 // no delay — start immediately
);
retryPath = 'resumed_monitoring';
} else {
// Download not found or is failed — re-add
logger.info(`Retry download: download not alive (status: ${downloadInfo?.status ?? 'not found'}), re-adding for request ${id}`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} else {
// No client configured for that protocol — fall through to re-add
logger.warn(`Retry download: no ${protocol} client configured, re-adding for request ${id}`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} catch (clientError) {
// Client lookup failed (connection error etc.) — re-add to be safe
logger.warn(`Retry download: client check failed, re-adding for request ${id}`, {
requestId: id,
error: clientError instanceof Error ? clientError.message : String(clientError),
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} else {
// Direct download (ebook), no clientId, or no clientType — re-add
logger.info(`Retry download: re-adding for request ${id} (direct=${isDirect}, hasClientId=${!!clientId})`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
// Increment downloadAttempts, clear errorMessage, set status to downloading
await prisma.request.update({
where: { id },
data: {
status: 'downloading',
errorMessage: null,
downloadAttempts: { increment: 1 },
updatedAt: new Date(),
},
});
const message =
retryPath === 'resumed_monitoring'
? 'Download monitoring resumed'
: 'Download re-added to client';
logger.info(`Retry download completed for request ${id} via ${retryPath}`, {
requestId: id,
adminId: req.user.sub,
path: retryPath,
});
return NextResponse.json({
success: true,
message,
path: retryPath,
});
} catch (error) {
logger.error('Failed to retry download', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'RetryError',
message: 'Failed to retry download',
},
{ status: 500 }
);
}
});
});
}
/**
* Re-add the download to the queue using metadata from DownloadHistory.
* Reconstructs a TorrentResult from the stored history fields.
*/
async function reAddDownload(
jobQueue: ReturnType<typeof getJobQueueService>,
requestId: string,
audiobook: { id: string; title: string; author: string },
history: {
torrentName: string | null;
magnetLink: string | null;
indexerName: string;
indexerId: number | null;
torrentSizeBytes: bigint | null;
seeders: number | null;
leechers: number | null;
torrentHash: string | null;
torrentUrl: string | null;
}
): Promise<void> {
const torrent: TorrentResult = {
title: history.torrentName ?? audiobook.title,
downloadUrl: history.magnetLink!, // Validated non-null before calling this function
indexer: history.indexerName,
indexerId: history.indexerId ?? undefined,
size: history.torrentSizeBytes !== null ? Number(history.torrentSizeBytes) : 0,
seeders: history.seeders ?? undefined,
leechers: history.leechers ?? undefined,
infoHash: history.torrentHash ?? undefined,
infoUrl: history.torrentUrl ?? undefined,
guid: history.torrentUrl ?? history.magnetLink!,
publishDate: new Date(), // Not stored; use current date as a safe default
};
await jobQueue.addDownloadJob(requestId, audiobook, torrent);
}
@@ -0,0 +1,133 @@
/**
* Component: Admin Custom Search Terms API
* Documentation: documentation/admin-dashboard.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';
const logger = RMABLogger.create('API.Admin.SearchTerms');
/**
* PATCH /api/admin/requests/[id]/search-terms
* Update custom search terms for a request (admin only)
* Body: { searchTerms: string | null, triggerSearch?: boolean }
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
// Parse body
let body;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: 'BadRequest', message: 'Invalid JSON body' },
{ status: 400 }
);
}
const { searchTerms, triggerSearch } = body;
// Validate searchTerms is string or null
if (searchTerms !== null && searchTerms !== undefined && typeof searchTerms !== 'string') {
return NextResponse.json(
{ error: 'BadRequest', message: 'searchTerms must be a string or null' },
{ status: 400 }
);
}
// Trim and normalize
const normalizedTerms = typeof searchTerms === 'string' ? searchTerms.trim() || null : null;
// Find the request
const existingRequest = await prisma.request.findUnique({
where: { id },
include: {
audiobook: {
select: { id: true, title: true, author: true, audibleAsin: true },
},
},
});
if (!existingRequest || existingRequest.deletedAt) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Update custom search terms
await prisma.request.update({
where: { id },
data: {
customSearchTerms: normalizedTerms,
updatedAt: new Date(),
},
});
logger.info(`Custom search terms ${normalizedTerms ? 'set' : 'cleared'} for request ${id}`, {
requestId: id,
customSearchTerms: normalizedTerms,
adminId: req.user.id,
});
// Optionally trigger a new search
let searchTriggered = false;
if (triggerSearch && ['pending', 'failed', 'awaiting_search'].includes(existingRequest.status)) {
// Reset status to pending and clear error
await prisma.request.update({
where: { id },
data: {
status: 'pending',
errorMessage: null,
updatedAt: new Date(),
},
});
// Queue search job
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined,
});
searchTriggered = true;
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
}
return NextResponse.json({
success: true,
customSearchTerms: normalizedTerms,
searchTriggered,
});
} catch (error) {
logger.error('Failed to update search terms', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to update search terms' },
{ status: 500 }
);
}
});
});
}
+2
View File
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
completedAt: request.completedAt,
errorMessage: request.errorMessage,
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms || null,
}));
return NextResponse.json({
@@ -0,0 +1,91 @@
/**
* Component: Admin Download Access Settings API
* Documentation: documentation/settings-pages.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';
const logger = RMABLogger.create('API.Admin.Settings.DownloadAccess');
const CONFIG_KEY = 'download_access';
/**
* GET /api/admin/settings/download-access
* Get current global download access setting
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const config = await prisma.configuration.findUnique({
where: { key: CONFIG_KEY },
});
// Default to true if not configured (backward compatibility)
const downloadAccess = config === null ? true : config.value === 'true';
return NextResponse.json({ downloadAccess });
} catch (error) {
logger.error('Failed to fetch download access setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to fetch download access setting' },
{ status: 500 }
);
}
});
});
}
/**
* PATCH /api/admin/settings/download-access
* Update global download access setting
*/
export async function PATCH(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { downloadAccess } = body;
// Validate input
if (typeof downloadAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid input. downloadAccess must be a boolean' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: CONFIG_KEY },
create: {
key: CONFIG_KEY,
value: downloadAccess.toString(),
},
update: {
value: downloadAccess.toString(),
},
});
logger.info(`Download access setting updated to: ${downloadAccess}`, {
userId: req.user?.sub,
});
return NextResponse.json({ downloadAccess });
} catch (error) {
logger.error('Failed to update download access setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to update download access setting' },
{ status: 500 }
);
}
});
});
}
+29 -1
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -97,6 +97,32 @@ export async function PUT(request: NextRequest) {
},
});
// Update file rename setting
await prisma.configuration.upsert({
where: { key: 'file_rename_enabled' },
update: { value: String(fileRenameEnabled ?? false) },
create: {
key: 'file_rename_enabled',
value: String(fileRenameEnabled ?? false),
category: 'automation',
description: 'Rename audio and ebook files using a custom naming template during organization',
},
});
// Update file rename template
if (fileRenameTemplate !== undefined) {
await prisma.configuration.upsert({
where: { key: 'file_rename_template' },
update: { value: fileRenameTemplate },
create: {
key: 'file_rename_template',
value: fileRenameTemplate,
category: 'automation',
description: 'Template for renaming audio and ebook files during organization',
},
});
}
logger.info('Paths settings updated');
// Clear config cache for all updated keys so services get fresh values
@@ -107,6 +133,8 @@ export async function PUT(request: NextRequest) {
configService.clearCache('ebook_path_template');
configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled');
configService.clearCache('file_rename_enabled');
configService.clearCache('file_rename_template');
// Invalidate all download client singletons to force reload of download_dir
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+2
View File
@@ -128,6 +128,8 @@ export async function GET(request: NextRequest) {
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
},
ebook: {
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
+20 -2
View File
@@ -19,7 +19,7 @@ export async function PUT(
try {
const { id } = await params;
const body = await request.json();
const { role, autoApproveRequests, interactiveSearchAccess } = body;
const { role, autoApproveRequests, interactiveSearchAccess, downloadAccess } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
@@ -45,6 +45,14 @@ export async function PUT(
);
}
// Validate downloadAccess (optional)
if (downloadAccess !== undefined && downloadAccess !== null && typeof downloadAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid downloadAccess. Must be a boolean or null' },
{ status: 400 }
);
}
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
@@ -112,15 +120,24 @@ export async function PUT(
{ status: 400 }
);
}
if (role === 'admin' && downloadAccess === false) {
return NextResponse.json(
{ error: 'Admins always have download access. Cannot set downloadAccess to false for admin users.' },
{ status: 400 }
);
}
// Prepare update data
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null; downloadAccess?: boolean | null } = { role };
if (autoApproveRequests !== undefined) {
updateData.autoApproveRequests = autoApproveRequests;
}
if (interactiveSearchAccess !== undefined) {
updateData.interactiveSearchAccess = interactiveSearchAccess;
}
if (downloadAccess !== undefined) {
updateData.downloadAccess = downloadAccess;
}
// Update user
const updatedUser = await prisma.user.update({
@@ -132,6 +149,7 @@ export async function PUT(
role: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
downloadAccess: true,
},
});
+1
View File
@@ -32,6 +32,7 @@ export async function GET(request: NextRequest) {
lastLoginAt: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
downloadAccess: true,
_count: {
select: {
requests: true,
@@ -0,0 +1,70 @@
/**
* Component: Audiobook Download Status API Route
* Documentation: documentation/backend/api.md
*
* Returns whether a downloadable file exists for this audiobook (by ASIN).
* Used by AudiobookDetailsModal to show the download link regardless of context.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import { resolveDownloadAccess } from '@/lib/utils/permissions';
/**
* GET /api/audiobooks/[asin]/download-status
* Returns { downloadAvailable, requestId } for the current user's completed request.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check download permission - if denied, don't reveal file existence
const userRecord = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, downloadAccess: true },
});
const hasDownloadAccess = await resolveDownloadAccess(
userRecord?.role ?? 'user',
userRecord?.downloadAccess ?? null
);
if (!hasDownloadAccess) {
return NextResponse.json({ downloadAvailable: false, requestId: null });
}
const { asin } = await params;
const audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
select: { id: true, filePath: true },
});
if (!audiobook) {
return NextResponse.json({ downloadAvailable: false, requestId: null });
}
// Find any completed request for this audiobook that has a file
const completedRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
status: { in: [...COMPLETED_STATUSES] },
deletedAt: null,
},
select: { id: true },
orderBy: { createdAt: 'desc' },
});
const downloadAvailable = !!completedRequest && !!audiobook.filePath;
return NextResponse.json({
downloadAvailable,
requestId: downloadAvailable ? completedRequest!.id : null,
});
});
}
+16 -7
View File
@@ -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,
}),
]);
+16 -7
View File
@@ -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,
}),
]);
+12 -2
View File
@@ -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,
});
+3 -1
View File
@@ -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) {
+9
View File
@@ -39,6 +39,7 @@ export async function GET(request: NextRequest) {
createdAt: true,
lastLoginAt: true,
interactiveSearchAccess: true,
downloadAccess: true,
},
});
@@ -63,6 +64,13 @@ export async function GET(request: NextRequest) {
globalInteractiveSearch
);
const globalDownload = await getGlobalBooleanSetting('download_access', true);
const effectiveDownload = resolvePermission(
user.role,
user.downloadAccess,
globalDownload
);
return NextResponse.json({
user: {
id: user.id,
@@ -77,6 +85,7 @@ export async function GET(request: NextRequest) {
lastLoginAt: user.lastLoginAt,
permissions: {
interactiveSearch: effectiveInteractiveSearch,
download: effectiveDownload,
},
},
});
+18 -4
View File
@@ -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';
@@ -46,16 +48,26 @@ export async function GET(
);
}
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin}), page ${page}`);
const audibleService = getAudibleService();
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
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(books, userId);
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
return NextResponse.json({
success: true,
@@ -63,6 +75,8 @@ export async function GET(
authorName: authorName.trim(),
authorAsin: asin,
totalBooks: enrichedBooks.length,
hasMore: result.hasMore,
page: result.page,
});
} catch (error) {
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
+3 -3
View File
@@ -59,9 +59,9 @@ async function saveConfig(req: AuthenticatedRequest) {
);
}
if (!['openai', 'claude', 'custom'].includes(provider)) {
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 }
);
}
@@ -107,7 +107,7 @@ async function saveConfig(req: AuthenticatedRequest) {
// No new API key, use existing one
encryptedApiKeyToUse = existingConfig.apiKey;
} else {
// API key required for OpenAI/Claude
// API key required for OpenAI/Claude/Gemini
return NextResponse.json(
{ error: 'API key is required' },
{ status: 400 }
+48 -4
View File
@@ -52,6 +52,30 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
return allModels;
}
// Fetch available Gemini models from the Google API
async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> {
const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': apiKey } }
);
if (!response.ok) {
const errorText = await response.text();
logger.error('Gemini API error', { error: errorText });
throw new Error('Invalid Gemini API key or connection failed');
}
const data = await response.json();
return (data.models || [])
.filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent'))
.map((m: any) => ({
id: m.name.replace('models/', ''),
name: m.displayName || m.name.replace('models/', ''),
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
}
// Helper functions for custom provider
function isValidBaseUrl(url: string): boolean {
try {
@@ -79,9 +103,9 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
);
}
if (!['openai', 'claude', 'custom'].includes(provider)) {
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 }
);
}
@@ -193,6 +217,16 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
{ status: 400 }
);
}
} else if (provider === 'gemini') {
// Gemini: Fetch models dynamically from the Google API
try {
models = await fetchGeminiModels(testApiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }
);
}
} else if (provider === 'custom') {
// Custom: Fetch models from custom OpenAI-compatible endpoint
const normalizedUrl = normalizeBaseUrl(testBaseUrl);
@@ -291,9 +325,9 @@ async function unauthenticatedHandler(req: NextRequest) {
);
}
if (!['openai', 'claude', 'custom'].includes(provider)) {
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 }
);
}
@@ -363,6 +397,16 @@ async function unauthenticatedHandler(req: NextRequest) {
{ status: 400 }
);
}
} else if (provider === 'gemini') {
// Gemini: Fetch models dynamically
try {
models = await fetchGeminiModels(apiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }
);
}
} else if (provider === 'custom') {
// Custom: Fetch models from custom OpenAI-compatible endpoint
const normalizedUrl = normalizeBaseUrl(baseUrl);
@@ -0,0 +1,89 @@
/**
* Component: On-Demand Download Token Generator
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { generateDownloadToken } from '@/lib/utils/jwt';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import { resolveDownloadAccess } from '@/lib/utils/permissions';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.DownloadToken');
/**
* POST /api/requests/[id]/download-token
* Generate a signed download token on demand (lazy token generation).
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
// Check download permission
const userRecord = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, downloadAccess: true },
});
const hasDownloadAccess = await resolveDownloadAccess(
userRecord?.role ?? 'user',
userRecord?.downloadAccess ?? null
);
if (!hasDownloadAccess) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have download access' },
{ status: 403 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: { audiobook: true },
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
if (!COMPLETED_STATUSES.includes(requestRecord.status as typeof COMPLETED_STATUSES[number])) {
return NextResponse.json(
{ error: 'BadRequest', message: 'Request is not yet completed' },
{ status: 400 }
);
}
if (!requestRecord.audiobook?.filePath) {
return NextResponse.json(
{ error: 'NotFound', message: 'No file available for this request' },
{ status: 404 }
);
}
const token = generateDownloadToken(req.user.id, id);
const downloadUrl = `/api/requests/${id}/download?token=${token}`;
return NextResponse.json({ downloadUrl });
} catch (error) {
logger.error('Failed to generate download token', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'TokenError', message: 'Failed to generate download token' },
{ status: 500 }
);
}
});
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Component: Request File Download Endpoint
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyDownloadToken } from '@/lib/utils/jwt';
import { RMABLogger } from '@/lib/utils/logger';
import { AUDIO_EXTENSIONS, EBOOK_EXTENSIONS } from '@/lib/constants/audio-formats';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import { PassThrough } from 'stream';
const logger = RMABLogger.create('API.Download');
function sanitizeFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200);
}
/**
* GET /api/requests/[id]/download?token=<JWT>
* Token-authenticated file download no session cookie required.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Unauthorized', message: 'Missing download token' }, { status: 401 });
}
const payload = verifyDownloadToken(token);
if (!payload) {
return NextResponse.json({ error: 'Unauthorized', message: 'Invalid or expired download token' }, { status: 401 });
}
if (payload.requestId !== id) {
return NextResponse.json({ error: 'Unauthorized', message: 'Token does not match request' }, { status: 401 });
}
const requestRecord = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: { audiobook: true },
});
if (!requestRecord) {
return NextResponse.json({ error: 'NotFound', message: 'Request not found' }, { status: 404 });
}
if (!COMPLETED_STATUSES.includes(requestRecord.status as typeof COMPLETED_STATUSES[number])) {
return NextResponse.json({ error: 'BadRequest', message: 'Request is not yet completed' }, { status: 400 });
}
if (!requestRecord.audiobook?.filePath) {
return NextResponse.json({ error: 'NotFound', message: 'No file path available for this request' }, { status: 404 });
}
const resolvedDir = path.resolve(requestRecord.audiobook.filePath);
if (!fs.existsSync(resolvedDir)) {
logger.error('Download directory does not exist', { path: resolvedDir });
return NextResponse.json({ error: 'NotFound', message: 'File directory not found on disk' }, { status: 404 });
}
const requestType = requestRecord.type || 'audiobook';
const allowedExtensions: readonly string[] = requestType === 'ebook' ? EBOOK_EXTENSIONS : AUDIO_EXTENSIONS;
const allEntries = fs.readdirSync(resolvedDir);
const matchingFiles = allEntries
.filter(name => allowedExtensions.includes(path.extname(name).toLowerCase()))
.map(name => path.join(resolvedDir, name));
if (matchingFiles.length === 0) {
return NextResponse.json({ error: 'NotFound', message: 'No matching files found in directory' }, { status: 404 });
}
const sanitizedTitle = sanitizeFilename(requestRecord.audiobook.title || 'download');
if (matchingFiles.length === 1) {
const filePath = matchingFiles[0];
const ext = path.extname(filePath);
const stat = fs.statSync(filePath);
const fileStream = fs.createReadStream(filePath);
const readableStream = new ReadableStream({
start(controller) {
fileStream.on('data', chunk => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', err => {
logger.error('File stream error', { error: err.message });
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(readableStream, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${sanitizedTitle}${ext}"`,
'Content-Length': String(stat.size),
},
});
}
// Multiple files — stream zip via archiver (avoids loading all files into memory)
const passThrough = new PassThrough();
const archive = archiver('zip', { zlib: { level: 6 } });
archive.pipe(passThrough);
for (const filePath of matchingFiles) {
archive.file(filePath, { name: path.basename(filePath) });
}
archive.finalize();
const zipReadable = new ReadableStream({
start(controller) {
passThrough.on('data', chunk => controller.enqueue(new Uint8Array(chunk)));
passThrough.on('end', () => controller.close());
passThrough.on('error', err => {
logger.error('Zip stream error', { error: err.message });
controller.error(err);
});
},
cancel() {
archive.abort();
},
});
return new NextResponse(zipReadable, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${sanitizedTitle}.zip"`,
},
});
} catch (error) {
logger.error('Download failed', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'DownloadError', message: 'Failed to serve file' }, { status: 500 });
}
}
@@ -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
@@ -67,8 +67,8 @@ export async function POST(
);
}
// Check if request is awaiting approval
if (requestRecord.status === 'awaiting_approval') {
// Check if request is awaiting approval (admins can still search to override the user's selection)
if (requestRecord.status === 'awaiting_approval' && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' },
{ status: 403 }
@@ -125,8 +125,8 @@ export async function POST(
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
// Use custom title if provided, otherwise use audiobook's title
const searchTitle = customTitle || requestRecord.audiobook.title;
// Use custom title if provided, then custom search terms, then audiobook's title
const searchTitle = customTitle || requestRecord.customSearchTerms || requestRecord.audiobook.title;
const searchAuthor = requestRecord.audiobook.author;
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
@@ -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({
+11 -2
View File
@@ -9,6 +9,7 @@ import { prisma } from '@/lib/db';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
import { createRequestForUser } from '@/lib/services/request-creator.service';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
const logger = RMABLogger.create('API.Requests');
@@ -146,10 +147,18 @@ export async function GET(request: NextRequest) {
take: limit,
});
const enriched = requests.map(r => {
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
// Strip server-side absolute path from client response
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
return { ...r, audiobook, downloadAvailable };
});
return NextResponse.json({
success: true,
requests,
count: requests.length,
requests: enriched,
count: enriched.length,
});
} catch (error) {
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
+18 -4
View File
@@ -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');
@@ -37,9 +39,11 @@ export async function GET(
);
}
logger.info(`Fetching series detail: ${asin}`);
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
const detail = await scrapeSeriesPage(asin);
logger.info(`Fetching series detail: ${asin}, page ${page}`);
const detail = await scrapeSeriesPage(asin, page);
if (!detail) {
return NextResponse.json(
{ error: 'NotFound', message: 'Series not found' },
@@ -47,11 +51,19 @@ 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)`);
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
return NextResponse.json({
success: true,
@@ -59,6 +71,8 @@ export async function GET(
...detail,
books: enrichedBooks,
},
hasMore: detail.hasMore,
page: detail.page,
});
} catch (error) {
logger.error('Failed to fetch series detail', {
+3 -2
View File
@@ -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
+59
View File
@@ -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 });
}
});
}
+141
View File
@@ -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 });
}
});
}
+2 -2
View File
@@ -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 });
}
});
}
+195
View File
@@ -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 },
);
}
});
}
+73
View File
@@ -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 });
}
});
}
+125
View File
@@ -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 });
}
});
}
+125
View File
@@ -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 });
}
});
}
+42 -12
View File
@@ -5,16 +5,17 @@
'use client';
import { use, useCallback } from 'react';
import { use, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function AuthorDetailPage({
@@ -27,11 +28,11 @@ export default function AuthorDetailPage({
const searchParams = useSearchParams();
const fromAuthorName = searchParams.get('from');
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
const { books, totalBooks, hasMore, isLoading: booksLoading, isLoadingMore, loadMore } = useAuthorBooks(
asin,
author?.name || null
);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /authors
@@ -42,6 +43,20 @@ export default function AuthorDetailPage({
}
}, [router]);
// Filter out available titles when hideAvailable is enabled
const filteredBooks = useMemo(
() => hideAvailable ? books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : books,
[books, hideAvailable]
);
// Header count text: reflects filtered counts
const visibleCount = filteredBooks.length;
const booksCountText = hasMore && totalBooks > books.length
? `${visibleCount.toLocaleString()} of ${totalBooks.toLocaleString()} title${totalBooks !== 1 ? 's' : ''}`
: visibleCount > 0
? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}`
: '';
return (
<ProtectedRoute>
<div className="min-h-screen">
@@ -91,27 +106,42 @@ export default function AuthorDetailPage({
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books
</h2>
{!booksLoading && totalBooks > 0 && (
{!booksLoading && booksCountText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
({booksCountText})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
{/* Books Grid */}
<AudiobookGrid
audiobooks={books}
audiobooks={filteredBooks}
isLoading={booksLoading}
emptyMessage={`No books found for ${author.name}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
{/* Load More Bar */}
{filteredBooks.length > 0 && (
<LoadMoreBar
loadedCount={filteredBooks.length}
totalCount={totalBooks > 0 ? totalBooks : undefined}
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={loadMore}
/>
)}
</div>
)}
</main>
+2 -1
View File
@@ -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;
})()}
+25
View File
@@ -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;
+52 -30
View File
@@ -5,20 +5,19 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { StickyPagination } from '@/components/ui/StickyPagination';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function HomePage() {
const [popularPage, setPopularPage] = useState(1);
const [newReleasesPage, setNewReleasesPage] = useState(1);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
// Refs for auto-scrolling to section tops
const popularSectionRef = useRef<HTMLElement>(null);
@@ -30,14 +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);
// 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) => {
@@ -66,10 +71,14 @@ export default function HomePage() {
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
Popular Audiobooks
</h2>
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
@@ -107,10 +116,14 @@ export default function HomePage() {
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
New Releases
</h2>
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
@@ -164,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>
+14 -3
View File
@@ -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>
);
+41 -37
View File
@@ -5,41 +5,48 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useSearch } from '@/lib/hooks/useAudiobooks';
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { useSearch, Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [page, setPage] = useState(1);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
setPage(1); // Reset to first page on new search
}, 500);
return () => clearTimeout(timer);
}, [query]);
const { results, totalResults, hasMore, isLoading } = useSearch(debouncedQuery, page);
const { results, totalResults, hasMore, isLoading, isLoadingMore, loadMore } = useSearch(debouncedQuery);
// Filter out available titles when hideAvailable is enabled
const filteredResults = useMemo(
() => hideAvailable ? results.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : results,
[results, hideAvailable]
);
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
setPage(1);
}, []);
const handleLoadMore = useCallback(() => {
setPage((prev) => prev + 1);
}, []);
// Header count text: reflects filtered counts
const visibleCount = filteredResults.length;
const countText = hasMore && totalResults > 0
? `${visibleCount.toLocaleString()} of ${totalResults.toLocaleString()} result${totalResults !== 1 ? 's' : ''}`
: visibleCount > 0
? `${visibleCount.toLocaleString()} result${visibleCount !== 1 ? 's' : ''}`
: '';
return (
<ProtectedRoute>
@@ -113,45 +120,42 @@ export default function SearchPage() {
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Search Results
</h2>
{!isLoading && totalResults > 0 && (
{!isLoading && countText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''})
({countText})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
{/* Results Grid */}
<AudiobookGrid
audiobooks={results}
isLoading={!!(isLoading && page === 1)}
audiobooks={filteredResults}
isLoading={isLoading}
emptyMessage={`No results found for "${debouncedQuery}"`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
{/* Load More */}
{hasMore && !isLoading && (
<div className="flex justify-center">
<button
onClick={handleLoadMore}
className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Load More Results
</button>
</div>
)}
{/* Loading More Indicator */}
{isLoading && page > 1 && (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{/* Load More Bar */}
{filteredResults.length > 0 && (
<LoadMoreBar
loadedCount={filteredResults.length}
totalCount={totalResults}
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={loadMore}
itemLabel="results"
/>
)}
</div>
) : (
+46 -12
View File
@@ -5,16 +5,17 @@
'use client';
import { use, useCallback } from 'react';
import { use, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
import { useSeriesDetail } from '@/lib/hooks/useSeries';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function SeriesDetailPage({
@@ -26,8 +27,8 @@ export default function SeriesDetailPage({
const router = useRouter();
const searchParams = useSearchParams();
const fromSeriesTitle = searchParams.get('from');
const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const { series, hasMore, isLoading: seriesLoading, isLoadingMore, loadMore } = useSeriesDetail(asin);
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /series
@@ -38,6 +39,24 @@ export default function SeriesDetailPage({
}
}, [router]);
// Filter out available titles when hideAvailable is enabled
const filteredBooks = useMemo(
() => series && hideAvailable
? series.books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed')
: series?.books ?? [],
[series, hideAvailable]
);
// Header count text: reflects filtered counts
const visibleCount = filteredBooks.length;
const booksCountText = series
? hasMore && series.bookCount > series.books.length
? `${visibleCount.toLocaleString()} of ${series.bookCount.toLocaleString()} title${series.bookCount !== 1 ? 's' : ''}`
: visibleCount > 0
? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}`
: ''
: '';
return (
<ProtectedRoute>
<div className="min-h-screen">
@@ -87,27 +106,42 @@ export default function SeriesDetailPage({
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books in Series
</h2>
{series.books.length > 0 && (
{booksCountText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({series.books.length} title{series.books.length !== 1 ? 's' : ''})
({booksCountText})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
{/* Books Grid */}
<AudiobookGrid
audiobooks={series.books}
audiobooks={filteredBooks}
isLoading={seriesLoading}
emptyMessage={`No books found for ${series.title}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
{/* Load More Bar */}
{filteredBooks.length > 0 && (
<LoadMoreBar
loadedCount={filteredBooks.length}
totalCount={series.bookCount > 0 ? series.bookCount : undefined}
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={loadMore}
/>
)}
</div>
)}
</main>
+2 -1
View File
@@ -134,6 +134,7 @@ export function BookDateStep({
>
<option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
</select>
</div>
@@ -152,7 +153,7 @@ export function BookDateStep({
onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
}}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : bookdateProvider === 'gemini' ? 'AIza...' : 'sk-ant-...'}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
@@ -14,6 +14,8 @@ interface GlobalUserSettingsModalProps {
onToggleAutoApprove: (newValue: boolean) => void;
globalInteractiveSearch: boolean;
onToggleInteractiveSearch: (newValue: boolean) => void;
globalDownloadAccess: boolean;
onToggleDownloadAccess: (newValue: boolean) => void;
}
export function GlobalUserSettingsModal({
@@ -23,6 +25,8 @@ export function GlobalUserSettingsModal({
onToggleAutoApprove,
globalInteractiveSearch,
onToggleInteractiveSearch,
globalDownloadAccess,
onToggleDownloadAccess,
}: GlobalUserSettingsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
@@ -84,6 +88,35 @@ export function GlobalUserSettingsModal({
</p>
</div>
</div>
{/* Download Access Setting */}
<div className="flex items-start gap-4">
<button
onClick={() => onToggleDownloadAccess(!globalDownloadAccess)}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: globalDownloadAccess ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={globalDownloadAccess}
aria-label="Download Access"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
globalDownloadAccess ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<label
onClick={() => onToggleDownloadAccess(!globalDownloadAccess)}
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Download Access
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
When enabled, all users can download audiobook files. When disabled, you can grant access per-user from the users table.
</p>
</div>
</div>
</div>
</Modal>
);
@@ -15,6 +15,7 @@ interface UserPermissionsUser {
role: 'user' | 'admin';
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
}
interface UserPermissionsModalProps {
@@ -23,8 +24,10 @@ interface UserPermissionsModalProps {
user: UserPermissionsUser | null;
globalAutoApprove: boolean;
globalInteractiveSearch: boolean;
globalDownloadAccess: boolean;
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
}
interface PermissionToggleProps {
@@ -86,8 +89,10 @@ export function UserPermissionsModal({
user,
globalAutoApprove,
globalInteractiveSearch,
globalDownloadAccess,
onToggleAutoApprove,
onToggleInteractiveSearch,
onToggleDownloadAccess,
}: UserPermissionsModalProps) {
if (!user) return null;
@@ -103,6 +108,11 @@ export function UserPermissionsModal({
const isSearchDisabled = isAdmin || isSearchGlobalOverride;
const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false);
// Download Access resolution
const isDownloadGlobalOverride = !isAdmin && globalDownloadAccess;
const isDownloadDisabled = isAdmin || isDownloadGlobalOverride;
const downloadValue = isAdmin ? true : isDownloadGlobalOverride ? true : (user.downloadAccess ?? false);
const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => {
if (isAdminUser) return adminMessage;
if (isGlobalOverride) return globalMessage;
@@ -176,6 +186,21 @@ export function UserPermissionsModal({
description="When enabled, this user can manually search and select torrents and ebooks"
onToggle={() => onToggleInteractiveSearch(user, !searchValue)}
/>
{/* Download Access Permission */}
<PermissionToggle
label="Download Access"
ariaLabel="Download Access"
value={downloadValue}
disabled={isDownloadDisabled}
disabledMessage={getDisabledMessage(
isAdmin, isDownloadGlobalOverride,
'Admins always have download access',
'Controlled by global download access setting'
)}
description="When enabled, this user can download audiobook files directly"
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
/>
</div>
</div>
</div>
+157
View File
@@ -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 &quot;Authorization: Bearer {'<token>'}&quot; {typeof window !== 'undefined' ? window.location.origin : ''}{endpoint.path}
</code>
</div>
</div>
);
}
+151
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+10 -3
View File
@@ -56,8 +56,13 @@ export function AudiobookCard({
const [showToast, setShowToast] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
const status = getStatusConfig(audiobook);
// Build a display-only audiobook with the local status override
const displayAudiobook = localRequestStatus !== undefined
? { ...audiobook, requestStatus: localRequestStatus }
: audiobook;
const status = getStatusConfig(displayAudiobook);
const handleRequest = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -69,6 +74,7 @@ export function AudiobookCard({
try {
await createRequest(audiobook);
setLocalRequestStatus('pending');
setShowToast(true);
setTimeout(() => setShowToast(false), 2500);
onRequestSuccess?.();
@@ -240,8 +246,9 @@ export function AudiobookCard({
isOpen={showModal}
onClose={() => setShowModal(false)}
onRequestSuccess={onRequestSuccess}
isRequested={audiobook.isRequested}
requestStatus={audiobook.requestStatus}
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
requestStatus={displayAudiobook.requestStatus}
isAvailable={audiobook.isAvailable}
requestedByUsername={audiobook.requestedByUsername}
hasReportedIssue={audiobook.hasReportedIssue}
@@ -13,23 +13,28 @@ import Image from 'next/image';
import Link from 'next/link';
import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useCreateRequest, useEbookStatus, useDownloadStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
import { fetchWithAuth } from '@/lib/utils/api';
interface AudiobookDetailsModalProps {
asin: string;
isOpen: boolean;
onClose: () => void;
onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
isRequested?: boolean;
requestStatus?: string | null;
isAvailable?: boolean;
requestedByUsername?: string | null;
hideRequestActions?: boolean;
hasReportedIssue?: boolean;
aiReason?: string | null;
}
// Status helper
@@ -63,18 +68,21 @@ export function AudiobookDetailsModal({
isOpen,
onClose,
onRequestSuccess,
onStatusChange,
isRequested = false,
requestStatus = null,
isAvailable = false,
requestedByUsername = null,
hideRequestActions = false,
hasReportedIssue = false,
aiReason = null,
}: AudiobookDetailsModalProps) {
const { user } = useAuth();
const { squareCovers } = usePreferences();
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
const [showToast, setShowToast] = useState(false);
@@ -84,9 +92,18 @@ export function AudiobookDetailsModal({
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showReportIssue, setShowReportIssue] = useState(false);
const [showManualImport, setShowManualImport] = useState(false);
const [asinCopied, setAsinCopied] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
const [isDownloading, setIsDownloading] = useState(false);
const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername);
// Sync local status when the prop changes (e.g. page data refreshes)
useEffect(() => {
setLocalRequestStatus(requestStatus ?? null);
}, [requestStatus]);
const effectiveStatus = localRequestStatus;
const status = getStatusInfo(isAvailable, effectiveStatus, requestedByUsername);
const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest;
useEffect(() => {
@@ -119,6 +136,8 @@ export function AudiobookDetailsModal({
try {
await createRequest(audiobook);
setLocalRequestStatus('pending');
onStatusChange?.('pending');
showNotification('Request created!');
setTimeout(onClose, 1500);
onRequestSuccess?.();
@@ -160,6 +179,22 @@ export function AudiobookDetailsModal({
}
};
const handleDownload = async () => {
if (!requestId) return;
setIsDownloading(true);
try {
const res = await fetchWithAuth(`/api/requests/${requestId}/download-token`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to get download link');
const { downloadUrl } = await res.json();
window.location.href = downloadUrl;
} catch (err) {
console.error('Failed to initiate download:', err);
showNotification('Failed to start download. Please try again.', 'error');
} finally {
setIsDownloading(false);
}
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
@@ -422,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">
@@ -461,6 +510,36 @@ export function AudiobookDetailsModal({
</svg>
</a>
</div>
{/* Download Link - subtle utility, visible from any context */}
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
<div>
<p className="text-gray-500 dark:text-gray-400">Download</p>
<button
onClick={handleDownload}
disabled={isDownloading}
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
aria-label={isDownloading ? 'Preparing download...' : 'Download audiobook files'}
>
{isDownloading ? (
<>
<svg className="w-3.5 h-3.5 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>Preparing...</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Download files</span>
</>
)}
</button>
</div>
)}
</div>
</div>
@@ -485,7 +564,8 @@ export function AudiobookDetailsModal({
)}
</div>
{/* Sticky Action Bar - hidden when opened from bookdate */}
{/* Sticky Action Bar - hidden when opened from read-only contexts */}
{audiobook && !isLoading && !hideRequestActions && (
<div
className="sticky bottom-0 z-20 p-4 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
@@ -556,6 +636,17 @@ export function AudiobookDetailsModal({
</button>
)}
{/* Manual Import - admin only, hidden during active processing and completed states */}
{user?.role === 'admin' && !isAvailable && !['downloading', 'processing', 'searching', 'downloaded', 'completed', 'available'].includes(effectiveStatus || '') && (
<button
onClick={() => setShowManualImport(true)}
className="p-3 rounded-xl bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 hover:bg-teal-200 dark:hover:bg-teal-900/50 transition-colors"
title="Manual Import"
>
<FolderArrowDownIcon className="w-6 h-6" />
</button>
)}
{/* Ebook Buttons - only when available and enabled */}
{canShowEbookButtons && user && (
<>
@@ -674,6 +765,26 @@ export function AudiobookDetailsModal({
coverArtUrl={audiobook.coverArtUrl}
/>
)}
{/* Manual Import Browser */}
{showManualImport && audiobook && (
<ManualImportBrowser
isOpen={showManualImport}
onClose={() => setShowManualImport(false)}
onSuccess={() => {
setLocalRequestStatus('processing');
onStatusChange?.('processing');
showNotification('Import started — files are being processed');
onRequestSuccess?.();
}}
audiobook={{
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
coverArtUrl: audiobook.coverArtUrl,
}}
/>
)}
</>
);
}
@@ -0,0 +1,308 @@
/**
* Component: Manual Import File Browser
* Documentation: documentation/features/manual-import.md
*
* Two-phase modal for browsing server directories and importing audiobook files.
* Phase 1 (BrowsePhase): Directory navigation with audio file detection.
* Phase 2 (ConfirmPhase): Review and start import.
*
* Sub-components: manual-import/BrowsePhase.tsx, manual-import/ConfirmPhase.tsx
*/
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { fetchWithAuth } from '@/lib/utils/api';
import { FolderArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { RootEntry, DirectoryEntry, AudioFileEntry, SlideDirection } from './manual-import/types';
import { BrowsePhase } from './manual-import/BrowsePhase';
import { ConfirmPhase } from './manual-import/ConfirmPhase';
interface ManualImportBrowserProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
audiobook: {
asin: string;
title: string;
author: string;
coverArtUrl?: string;
};
}
type Phase = 'browse' | 'confirm';
export function ManualImportBrowser({
isOpen,
onClose,
onSuccess,
audiobook,
}: ManualImportBrowserProps) {
const [phase, setPhase] = useState<Phase>('browse');
const [slideDirection, setSlideDirection] = useState<SlideDirection>('right');
// Browse state
const [roots, setRoots] = useState<RootEntry[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
const [selectedSize, setSelectedSize] = useState(0);
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
const [pathHistory, setPathHistory] = useState<string[]>([]);
// Loading/error state
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
// Cleanup source toggle
const [cleanupSource, setCleanupSource] = useState(false);
// Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
// Fetch roots on open
useEffect(() => {
if (!isOpen) return;
setPhase('browse');
setCurrentPath(null);
setSelectedPath(null);
setPathHistory([]);
fetchRoots();
}, [isOpen]);
const fetchRoots = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth('/api/admin/filesystem/browse');
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to load directories');
}
const data = await res.json();
setRoots(data.roots || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setIsLoading(false);
}
};
const fetchDirectory = useCallback(async (dirPath: string) => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth(
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
);
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to browse directory');
}
const data = await res.json();
setEntries(data.entries || []);
setCurrentAudioFiles(data.audioFiles || []);
setCurrentPath(data.path || dirPath);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to browse directory');
} finally {
setIsLoading(false);
}
}, []);
const navigateInto = (dirPath: string) => {
setSlideDirection('right');
if (currentPath) {
setPathHistory((prev) => [...prev, currentPath]);
}
setSelectedPath(null);
fetchDirectory(dirPath);
};
const navigateBack = () => {
setSlideDirection('left');
setSelectedPath(null);
if (pathHistory.length > 0) {
const prevPath = pathHistory[pathHistory.length - 1];
setPathHistory((prev) => prev.slice(0, -1));
fetchDirectory(prevPath);
} else {
setCurrentPath(null);
setEntries([]);
}
};
const navigateToRoot = () => {
setSlideDirection('left');
setSelectedPath(null);
setCurrentPath(null);
setEntries([]);
setCurrentAudioFiles([]);
setPathHistory([]);
};
const navigateToBreadcrumb = (index: number) => {
if (!currentPath) return;
setSlideDirection('left');
setSelectedPath(null);
const allPaths = [...pathHistory, currentPath];
const targetPath = allPaths[index];
if (targetPath) {
setPathHistory(allPaths.slice(0, index));
fetchDirectory(targetPath);
} else {
navigateToRoot();
}
};
const handleFolderClick = (entry: DirectoryEntry) => {
const fullPath = currentPath + '/' + entry.name;
navigateInto(fullPath);
};
const handleSelectCurrentFolder = () => {
if (!currentPath || currentAudioFiles.length === 0) return;
setSelectedPath(currentPath);
setSelectedAudioCount(currentAudioFiles.length);
setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0));
setSelectedAudioFiles(currentAudioFiles);
setSlideDirection('right');
setPhase('confirm');
};
const handleBackToBrowse = () => {
setSlideDirection('left');
setPhase('browse');
};
const handleStartImport = async () => {
if (!selectedPath) return;
setIsImporting(true);
setImportError(null);
try {
const res = await fetchWithAuth('/api/admin/manual-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asin: audiobook.asin,
folderPath: selectedPath,
cleanupSource,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Import failed');
}
onSuccess();
onClose();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Import failed');
} finally {
setIsImporting(false);
}
};
// Build breadcrumb segments
const breadcrumbs = (() => {
if (!currentPath) return [];
const allPaths = [...pathHistory, currentPath];
return allPaths.map((p) => {
const parts = p.replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || p;
});
})();
const visibleBreadcrumbs = (() => {
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
return [
{ label: breadcrumbs[0], index: 0 },
{ label: '...', index: -1 },
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
];
})();
if (!isOpen) return null;
const slideClass =
slideDirection === 'right'
? 'animate-[slideRight_200ms_ease-out]'
: 'animate-[slideLeft_200ms_ease-out]';
const modalContent = (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
style={{ height: '100dvh' }}
onClick={onClose}
>
<div
className="relative w-full max-w-2xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
style={{ height: 'min(640px, 85vh)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
<div className="flex items-center gap-2.5">
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{phase === 'browse' ? 'Manual Import' : 'Confirm Import'}
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{phase === 'browse' ? (
<BrowsePhase
roots={roots}
currentPath={currentPath}
entries={entries}
currentAudioFiles={currentAudioFiles}
isLoading={isLoading}
error={error}
hoveredFolder={hoveredFolder}
breadcrumbs={visibleBreadcrumbs}
slideClass={slideClass}
onNavigateInto={navigateInto}
onNavigateBack={navigateBack}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={navigateToBreadcrumb}
onFolderClick={handleFolderClick}
onSelectCurrentFolder={handleSelectCurrentFolder}
onHoverFolder={setHoveredFolder}
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
/>
) : (
<ConfirmPhase
audiobook={audiobook}
selectedPath={selectedPath!}
audioFileCount={selectedAudioCount}
totalSize={selectedSize}
audioFiles={selectedAudioFiles}
isImporting={isImporting}
importError={importError}
slideClass={slideClass}
cleanupSource={cleanupSource}
onCleanupSourceChange={setCleanupSource}
onBack={handleBackToBrowse}
onStartImport={handleStartImport}
/>
)}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
@@ -0,0 +1,278 @@
/**
* Component: Manual Import Browse Phase
* Documentation: documentation/features/manual-import.md
*
* Directory listing with root tiles, breadcrumb navigation,
* folder metadata, audio file badges, and selection state.
*/
'use client';
import React from 'react';
import {
FolderIcon,
FolderOpenIcon,
FolderArrowDownIcon,
InboxArrowDownIcon,
HomeIcon,
ChevronRightIcon,
ArrowLeftIcon,
MusicalNoteIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import { RootEntry, DirectoryEntry, AudioFileEntry, formatBytes } from './types';
function SkeletonRow() {
return (
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="flex-1 space-y-1.5">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
</div>
</div>
);
}
interface BrowsePhaseProps {
roots: RootEntry[];
currentPath: string | null;
entries: DirectoryEntry[];
currentAudioFiles: AudioFileEntry[];
isLoading: boolean;
error: string | null;
hoveredFolder: string | null;
breadcrumbs: Array<{ label: string; index: number }>;
slideClass: string;
onNavigateInto: (path: string) => void;
onNavigateBack: () => void;
onNavigateToRoot: () => void;
onNavigateToBreadcrumb: (index: number) => void;
onFolderClick: (entry: DirectoryEntry) => void;
onSelectCurrentFolder: () => void;
onHoverFolder: (name: string | null) => void;
onRetry: () => void;
}
export function BrowsePhase({
roots,
currentPath,
entries,
currentAudioFiles,
isLoading,
error,
hoveredFolder,
breadcrumbs,
slideClass,
onNavigateInto,
onNavigateBack,
onNavigateToRoot,
onNavigateToBreadcrumb,
onFolderClick,
onSelectCurrentFolder,
onHoverFolder,
onRetry,
}: BrowsePhaseProps) {
return (
<div className="flex flex-col h-full">
{/* Breadcrumb bar */}
{currentPath && (
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
<button
onClick={onNavigateToRoot}
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
{breadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
{crumb.index === -1 ? (
<span className="text-gray-400 px-1">...</span>
) : i === breadcrumbs.length - 1 ? (
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
{crumb.label}
</span>
) : (
<button
onClick={() => onNavigateToBreadcrumb(crumb.index)}
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
>
{crumb.label}
</button>
)}
</React.Fragment>
))}
</div>
)}
{/* Listing */}
<div className={`flex-1 overflow-y-auto ${slideClass}`}>
{/* Loading */}
{isLoading && (
<div className="py-2">
{[...Array(5)].map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
)}
{/* Error */}
{error && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 px-6">
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
<button
onClick={onRetry}
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
>
<ArrowPathIcon className="w-4 h-4" />
Try Again
</button>
</div>
)}
{/* Root view */}
{!currentPath && !isLoading && !error && (
<div className="p-5 grid grid-cols-2 gap-3">
{roots.map((root) => (
<button
key={root.path}
onClick={() => onNavigateInto(root.path)}
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
>
{root.icon === 'download' ? (
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
) : root.icon === 'bookdrop' ? (
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
) : (
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{root.name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
{root.path}
</span>
</button>
))}
</div>
)}
{/* Directory + audio file listing */}
{currentPath && !isLoading && !error && (entries.length > 0 || currentAudioFiles.length > 0) && (
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Subdirectories */}
{entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name;
return (
<button
key={`dir-${entry.name}`}
onClick={() => onFolderClick(entry)}
onMouseEnter={() => onHoverFolder(entry.name)}
onMouseLeave={() => onHoverFolder(null)}
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
{isHovered ? (
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
) : (
<FolderIcon className="w-5 h-5" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{entry.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{entry.subfolderCount > 0 && (
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
)}
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> &middot; </span>}
{entry.audioFileCount > 0 && (
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
)}
{entry.totalSize > 0 && (
<span> &middot; {formatBytes(entry.totalSize)}</span>
)}
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
<span className="italic">Empty</span>
)}
</p>
</div>
{hasAudio && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
<MusicalNoteIcon className="w-3 h-3" />
{entry.audioFileCount}
</span>
)}
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</button>
);
})}
{/* Audio files in current directory */}
{currentAudioFiles.length > 0 && entries.length > 0 && (
<div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20">
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
Audio Files
</p>
</div>
)}
{currentAudioFiles.map((file) => (
<div
key={`file-${file.name}`}
className="flex items-center gap-3 px-4 py-2.5"
>
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
{file.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
{formatBytes(file.size)}
</span>
</div>
))}
</div>
)}
{/* Empty state */}
{currentPath && !isLoading && !error && entries.length === 0 && currentAudioFiles.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
<button
onClick={onNavigateBack}
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<ArrowLeftIcon className="w-4 h-4" />
Go back
</button>
</div>
)}
</div>
{/* Footer: Select this folder */}
{currentPath && !isLoading && currentAudioFiles.length > 0 && (
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
</p>
<button
onClick={onSelectCurrentFolder}
className="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
>
Select This Folder &rarr;
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,170 @@
/**
* Component: Manual Import Confirm Phase
* Documentation: documentation/features/manual-import.md
*
* Shows book context, selected folder, pipeline steps summary,
* and start import / back actions.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import { ArrowLeftIcon, ExclamationCircleIcon, MusicalNoteIcon } from '@heroicons/react/24/outline';
import { AudioFileEntry, formatBytes } from './types';
interface ConfirmPhaseProps {
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
selectedPath: string;
audioFileCount: number;
totalSize: number;
audioFiles: AudioFileEntry[];
isImporting: boolean;
importError: string | null;
slideClass: string;
cleanupSource: boolean;
onCleanupSourceChange: (value: boolean) => void;
onBack: () => void;
onStartImport: () => void;
}
export function ConfirmPhase({
audiobook,
selectedPath,
audioFileCount,
totalSize,
audioFiles,
isImporting,
importError,
slideClass,
cleanupSource,
onCleanupSourceChange,
onBack,
onStartImport,
}: ConfirmPhaseProps) {
return (
<div className={`flex flex-col h-full ${slideClass}`}>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Book context */}
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-gray-100 dark:bg-gray-800">
{audiobook.coverArtUrl ? (
<Image
src={audiobook.coverArtUrl}
alt=""
width={64}
height={64}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<MusicalNoteIcon className="w-6 h-6 text-gray-400" />
</div>
)}
</div>
<div className="min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{audiobook.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{audiobook.author}</p>
</div>
</div>
{/* Selected folder info */}
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1.5">
Import from
</p>
<p className="text-sm font-mono text-gray-900 dark:text-gray-100 break-all">
{selectedPath}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''}
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''}
</p>
</div>
{/* Audio files to import */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
Files to import
</h4>
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
{audioFiles.map((file) => (
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
{file.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
{formatBytes(file.size)}
</span>
</div>
))}
</div>
</div>
{/* Cleanup source toggle */}
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Cleanup source files
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Delete original files after successful import
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={cleanupSource}
onChange={(e) => onCleanupSourceChange(e.target.checked)}
disabled={isImporting}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
{/* Error display */}
{importError && (
<div className="mx-5 mb-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 flex items-start gap-2.5">
<ExclamationCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-300">{importError}</p>
</div>
)}
{/* Footer */}
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-3">
<button
onClick={onBack}
disabled={isImporting}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors disabled:opacity-50"
>
<ArrowLeftIcon className="w-4 h-4" />
Back
</button>
<button
onClick={onStartImport}
disabled={isImporting}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
>
{isImporting ? (
<>
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Importing...
</>
) : (
'Start Import'
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
/**
* Component: Manual Import Shared Types
* Documentation: documentation/features/manual-import.md
*/
export interface RootEntry {
name: string;
path: string;
icon: string;
}
export interface DirectoryEntry {
name: string;
type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
}
export interface AudioFileEntry {
name: string;
size: number;
}
export type SlideDirection = 'left' | 'right';
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
+22 -14
View File
@@ -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 && (
+43 -29
View File
@@ -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>
);
}
+234
View File
@@ -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&apos;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">
&ldquo;{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}&rdquo;
</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>
);
}
@@ -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&apos;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>
);
}

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