Compare commits

...

104 Commits

Author SHA1 Message Date
2a7881ac6e chore: bump version to 2.0.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m7s
2026-03-24 12:56:40 +01:00
0950018b38 fix: add autoComplete=off on password fields to suppress WebKit autofill error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:49:02 +01:00
bc796f4ee5 feat: multi-user reading progress & backoffice impersonation
- Scope all reading progress (books, series, stats) by user via
  Option<Extension<AuthUser>> — admin sees aggregate, read token sees own data
- Fix duplicate book rows when admin views lists (IS NOT NULL guard on JOIN)
- Add X-As-User header support: admin can impersonate any user from backoffice
- UserSwitcher dropdown in nav header (persisted via as_user_id cookie)
- Per-user filter pills on "Currently reading" and "Recently read" dashboard sections
- Inline username editing (UsernameEdit component with optimistic update)
- PATCH /admin/users/:id endpoint to rename a user
- Unassigned read tokens row in users table
- Komga sync now requires a user_id — reading progress attributed to selected user
- Migration 0051: add user_id column to komga_sync_reports
- Nav breakpoints: icons-only from md, labels from xl, hamburger until md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:47:58 +01:00
232ecdda41 feat: add backoffice authentication with login page
- Add login page with logo background, glassmorphism card
- Add session management via JWT (jose) with httpOnly cookie
- Add Next.js proxy middleware to protect all routes
- Add logout button in nav
- Restructure app into (app) route group to isolate login layout
- Add ADMIN_USERNAME, ADMIN_PASSWORD, SESSION_SECRET env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:48:01 +01:00
32d13984a1 chore: bump version to 1.28.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 53s
2026-03-23 19:00:30 +01:00
eab7f2e21b feat: filter metadata refresh to ongoing series & improve job action buttons
- Metadata refresh now skips series with ended/cancelled status
- Add xs size to Button component
- Unify view/cancel button sizes (h-7) with icons (eye & cross)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:59:33 +01:00
b6422fbf3e feat: enhance jobs list stats with tooltips, icons, and refresh count
- Add Tooltip UI component for styled hover tooltips
- Replace native title attributes with Tooltip on all job stats
- Add refresh icon (green) showing actual refreshed count for metadata refresh
- Add icon+tooltip to scanned files stat
- Add icon prop to StatBox component
- Add refreshed field to stats_json types
- Distinct tooltip labels for total links vs refreshed count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:56:42 +01:00
6dbd0c80e6 feat: improve Telegram notification UI with better formatting
Add visual separators, contextual emojis, bold labels, structured
result sections, and conditional error lines for cleaner messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:46:25 +01:00
0c42a9ed04 fix: add API job poller to process scheduler-created metadata jobs
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m12s
The scheduler (indexer) created metadata_refresh/metadata_batch jobs in DB,
but the indexer excluded them (API_ONLY_JOB_TYPES) and the API only processed
jobs created via its REST endpoints. Scheduler-created jobs stayed pending forever.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:05:42 +01:00
95a6e54d06 chore: bump version to 1.27.1 2026-03-22 21:05:23 +01:00
e26219989f feat: add job runs chart and scrollable reading lists on dashboard
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
- Add multi-line chart showing job runs over time by type (scan,
  rebuild, thumbnails, other) with the same day/week/month toggle
- Limit currently reading and recently read lists to 3 visible items
  with a scrollbar for overflow
- Fix NUMERIC→BIGINT cast for SUM/COALESCE in jobs SQL queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:43:45 +01:00
5d33a35407 chore: bump version to 1.27.0 2026-03-22 10:43:25 +01:00
d53572dc33 chore: bump version to 1.26.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-03-22 10:27:59 +01:00
cf1953d11f feat: add day/week/month period toggle for dashboard line charts
Add a period selector (day, week, month) to the reading activity and
books added charts. The API now accepts a ?period= query param and
returns gap-filled data using generate_series so all time slots appear
even with zero values. Labels are locale-aware (short month, weekday).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:27:24 +01:00
6f663eaee7 docs: add MIT license
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:08:15 +01:00
ee65c6263a perf: add ETag and server-side caching for thumbnail proxy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 49s
Add ETag header to API thumbnail responses for 304 Not Modified support.
Forward If-None-Match/ETag through the Next.js proxy route handler and
add next.revalidate for 24h server-side fetch caching to reduce
SSR-to-API round trips on the libraries page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:52:47 +01:00
691b6b22ab chore: bump version to 1.25.0 2026-03-22 06:52:02 +01:00
11c80a16a3 docs: add Telegram notifications and updated dashboard to README and FEATURES
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:40:34 +01:00
c366b44c54 chore: bump version to 1.24.1 2026-03-22 06:39:23 +01:00
92f80542e6 perf: skip Next.js image re-optimization and stream proxy responses
Thumbnails are already optimized (WebP) by the API, so disable Next.js
image optimization to avoid redundant CPU work. Switch route handlers
from buffering (arrayBuffer) to streaming (response.body) to reduce
memory usage and latency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:38:46 +01:00
3a25e42a20 chore: bump version to 1.24.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m7s
2026-03-22 06:31:56 +01:00
24763bf5a7 fix: show absolute date/time in jobs "created" column
Replace relative time formatting (which incorrectly showed "just now"
for many jobs due to negative time diffs from server/client timezone
mismatch) with absolute locale-formatted date/time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:31:37 +01:00
08f0397029 feat: add reading stats and replace dashboard charts with recharts
Add currently reading, recently read, and reading activity sections to
the dashboard. Replace all custom SVG/CSS charts with recharts library
(donut, area, stacked bar, horizontal bar). Reorganize layout: libraries
and popular series side by side, books added chart full width below.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:26:45 +01:00
766e3a01b2 chore: bump version to 1.23.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 45s
2026-03-21 17:43:11 +01:00
626e2e035d feat: send book thumbnails in Telegram notifications
Use Telegram sendPhoto API for conversion and metadata-approved events
when a book thumbnail is available on disk. Falls back to text message
if photo upload fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:43:01 +01:00
cfd2321db2 chore: bump version to 1.22.0 2026-03-21 17:40:22 +01:00
1b715033ce fix: add missing Next.js route handler for Telegram test endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:39:46 +01:00
81d1586501 feat: add Telegram notification system with granular event toggles
Add notifications crate shared between API and indexer to send Telegram
messages on scan/thumbnail/conversion completion/failure, metadata linking,
batch and refresh events. Configurable via a new Notifications tab in the
backoffice settings with per-event toggle switches grouped by category.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:24:43 +01:00
bd74c9e3e3 docs: add comprehensive features list to README and docs/FEATURES.md
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m1s
Replace the minimal README features section with a concise categorized
summary and link to a detailed docs/FEATURES.md covering all features,
business rules, API endpoints, and integrations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:34:36 +01:00
41228430cf chore: bump version to 1.21.2 2026-03-21 14:34:32 +01:00
6a4ba06fac fix: include series_metadata authors in authors listing and detail pages
Authors were only sourced from books.authors/books.author fields which are
often empty. Now also aggregates authors from series_metadata.authors
(populated by metadata providers like bedetheque). Adds author filter to
/series endpoint and updates the author detail page to use it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:34:11 +01:00
e5c3542d3f refactor: split books.rs into books+series, reorganize OpenAPI tags and fix access control
- Extract series code from books.rs into dedicated series.rs module
- Reorganize OpenAPI tags: split overloaded "books" tag into books, series, search, stats
- Add missing endpoints to OpenAPI: metadata_batch, metadata_refresh, komga, update_metadata_provider
- Add missing schemas: MissingVolumeInput, Komga/Batch/Refresh DTOs
- Fix access control: move GET /libraries and POST /libraries/:id/scan to read routes
  so non-admin tokens can list libraries and trigger scans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:23:19 +01:00
24516f1069 chore: bump version to 1.21.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 41s
2026-03-21 13:42:17 +01:00
5383cdef60 feat: allow batch metadata and refresh metadata on all libraries
When no specific library is selected, iterate over all libraries and
trigger a job for each one, skipping libraries with metadata disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:42:08 +01:00
be5c3f7a34 fix: pass explicit locale to date formatting to prevent hydration mismatch
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 41s
Server and client could use different default locales for
toLocaleDateString/toLocaleString, causing React hydration errors.
Pass the user locale explicitly in JobsList and SettingsPage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:36:35 +01:00
caa9922ff9 chore: bump version to 1.21.0 2026-03-21 13:34:47 +01:00
135f000c71 refactor: switch JobsIndicator from polling to SSE and fix stream endpoint
Replace fetch polling in JobsIndicator with EventSource connected to
/api/jobs/stream. Fix the SSE route to return all jobs (via
/index/status) instead of only active ones, since JobsList also
consumes this stream for the full job history. JobsIndicator now
filters active jobs client-side. SSE server-side uses adaptive
interval (2s active, 15s idle) and only sends when data changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:33:58 +01:00
d9e50a4235 chore: bump version to 1.20.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m13s
2026-03-21 13:13:39 +01:00
5f6eb5a5cb perf: add selective fetch caching for stable API endpoints
Make apiFetch support Next.js revalidate option instead of
hardcoding cache: no-store on every request. Stable endpoints
(libraries, settings, stats, series statuses) now use time-based
revalidation while dynamic data (books, search, jobs) stays uncached.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:13:28 +01:00
41c77fca2e chore: bump version to 1.20.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m15s
2026-03-21 13:06:28 +01:00
49621f3fb1 perf: wrap BookCard and BookImage with React.memo
Prevent unnecessary re-renders of book grid items when parent
components update without changing book data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:03:24 +01:00
6df743b2e6 perf: lazy-load heavy modal components with next/dynamic
Dynamic import EditBookForm, EditSeriesForm, MetadataSearchModal, and
ProwlarrSearchModal so their code is split into separate chunks and
only fetched when the user interacts with them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:02:10 +01:00
edfefc0128 perf: optimize JobsIndicator polling with visibility API and adaptive interval
Pause polling when the tab is hidden, refetch immediately when it
becomes visible again, and use a 30s interval when no jobs are active
instead of polling every 2s unconditionally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:59:06 +01:00
b0185abefe perf: enable Next.js image optimization across backoffice
Remove `unoptimized` flag from all thumbnail/cover Image components
and add proper responsive `sizes` props. Convert raw `<img>` tags on
the libraries page to next/image. Add 24h minimumCacheTTL for
optimized images. BookPreview keeps `unoptimized` since the API
already returns optimized WebP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:57:10 +01:00
b9e54cbfd8 chore: bump version to 1.19.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 54s
2026-03-21 12:47:31 +01:00
3f0bd783cd feat: include series_count and thumbnail_book_ids in libraries API response
Eliminates N+1 sequential fetchSeries calls on the libraries page by
returning series count and up to 5 thumbnail book IDs (one per series)
directly from GET /libraries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:47:10 +01:00
fc8856c83f chore: bump version to 1.19.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m19s
2026-03-21 08:12:19 +01:00
bd09f3d943 feat: persist filter state in localStorage across pages
Save/restore filter values in LiveSearchForm using localStorage keyed
by basePath (e.g. filters:/books, filters:/series). Filters are restored
on mount when the URL has no active filters, and cleared when the user
clicks the Clear button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:12:10 +01:00
1f434c3d67 feat: add format and metadata filters to books page
Add two new filters to the books listing page:
- Format filter (CBZ/CBR/PDF/EPUB) using existing API support
- Metadata linked/unlinked filter with new API support via
  LEFT JOIN on external_metadata_links (using DISTINCT ON CTE
  matching the series endpoint pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:09:37 +01:00
4972a403df chore: bump version to 1.18.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m7s
2026-03-21 07:47:52 +01:00
629708cdd0 feat: redesign libraries page UI with fan thumbnails and modal settings
- Replace thumbnail mosaic with fan/arc layout using series covers as background
- Move library settings from dropdown to full-page portal modal with sections
- Move FolderPicker modal to portal for proper z-index stacking
- Add descriptions to each setting for better clarity
- Move delete button to card header, compact config tags
- Add i18n keys for new labels and descriptions (en/fr)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:47:36 +01:00
560087a897 chore: bump version to 1.17.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m12s
2026-03-21 07:23:52 +01:00
27f553b005 feat: add rescan job type and improve full rebuild UX
Add "Deep rescan" job type that clears directory mtimes to force
re-walking all directories, discovering newly supported formats (e.g.
EPUB) without deleting existing data or metadata.

Also improve full rebuild button: red destructive styling instead of
warning, and FR description explicitly mentions metadata/reading status
loss. Rename FR rebuild label to "Mise à jour".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:23:38 +01:00
ed7665248e chore: bump version to 1.16.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
2026-03-21 07:06:28 +01:00
736b8aedc0 feat: add EPUB format support with spine-aware image extraction
Parse EPUB structure (container.xml → OPF → spine → XHTML) to extract
images in reading order. Zero new dependencies — reuses zip + regex
crates with pre-compiled regexes and per-file index cache for
performance. Falls back to CBZ-style image listing when spine contains
no images. Includes DB migration, API/indexer/backoffice updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:05:47 +01:00
3daa49ae6c feat: add live refresh to job detail page via SSE
The job detail page was only server-rendered with no live updates,
unlike the jobs list page. Add a lightweight JobDetailLive client
component that subscribes to the existing SSE endpoint and calls
router.refresh() on each update, keeping the page in sync while
a job is running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 06:52:57 +01:00
5fb24188e1 chore: bump version to 1.15.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 44s
2026-03-20 13:35:36 +01:00
54f972db17 chore: bump version to 1.14.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 45s
2026-03-20 12:48:14 +01:00
acd8b62382 chore: bump version to 1.13.0 2026-03-20 12:44:54 +01:00
cc65e3d1ad feat: highlight missing volumes in Prowlarr search results
API extracts volume numbers from release titles and matches them against
missing volumes sent by the frontend. Matched results are highlighted in
green with badges indicating which missing volumes were found.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:44:35 +01:00
70889ca955 chore: bump version to 1.12.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 43s
2026-03-20 11:43:34 +01:00
4ad6d57271 feat: add authors page to backoffice with dedicated API endpoint
Add a new GET /authors endpoint that aggregates unique authors from books
with book/series counts, pagination and search. Add author filter to
GET /books. Backoffice gets a list page with search/sort and a detail
page showing the author's series and books.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:43:22 +01:00
fe5de3d5c1 feat: add scheduled metadata refresh for libraries
Add metadata_refresh_mode (manual/hourly/daily/weekly) to libraries,
with automatic scheduling via the indexer. Includes API support,
backoffice UI controls, i18n translations, and DB migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:51:52 +01:00
5a224c48c0 chore: bump version to 1.11.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 44s
2026-03-20 10:46:34 +01:00
d08fe31b1b fix: pass metadata_refresh_mode through backoffice proxy to API
The Next.js monitoring route was dropping metadata_refresh_mode from the
request body, so the value was never forwarded to the Rust API and
reverted on reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:46:22 +01:00
4d69ed91c5 chore: bump version to 1.11.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 56s
2026-03-20 09:46:29 +01:00
c6ddd3e6c7 chore: bump version to 1.10.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 49s
2026-03-19 22:33:52 +01:00
504185f31f feat: add editable search input to Prowlarr modal with scrollable badges
- Add text input for custom search queries in Prowlarr modal
- Quick search badges pre-fill the input and trigger search
- Default query uses quoted series name for exact match
- Add custom_query support to backend API
- Limit badge area height with vertical scroll
- Add debug logging for Prowlarr API responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 22:33:40 +01:00
acd0cce3f8 fix: reorder Prowlarr button, add collection progress bar, remove redundant missing badge
- Move Prowlarr search button before Metadata button
- Add amber collection progress bar showing owned/expected books ratio
- Remove yellow missing count badge from MetadataSearchModal (now shown in progress bar)
- Fix i18n plural parameter for series read count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 22:17:49 +01:00
e14da4fc8d chore: bump version to 1.10.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 51s
2026-03-19 21:51:45 +01:00
c04d4fb618 feat: add qBittorrent download client integration
Send Prowlarr search results directly to qBittorrent from the modal.
Backend authenticates via SID cookie (login + add torrent endpoints).

- Backend: qbittorrent module with add and test endpoints
- Migration: add qbittorrent settings (url, username, password)
- Settings UI: qBittorrent config card with test connection
- ProwlarrSearchModal: send-to-qBittorrent button per result row
  with spinner/checkmark state progression
- Button only shown when qBittorrent is configured

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:51:28 +01:00
57bc82703d feat: add Prowlarr integration for manual release search
Add Prowlarr indexer integration (step 1: config + manual search).
Allows searching for comics/ebooks releases on Prowlarr indexers
directly from the series detail page, with download links and
per-volume search for missing books.

- Backend: new prowlarr module with search and test endpoints
- Migration: add prowlarr settings (url, api_key, categories)
- Settings UI: Prowlarr config card with test connection button
- ProwlarrSearchModal: auto-search on open, missing volumes shortcuts
- Fix series.readCount i18n plural parameter on series pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:43:34 +01:00
e6aa7ebed0 chore: bump version to 1.9.2
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 44s
2026-03-19 13:22:41 +01:00
c44b51d6ef fix: unmap status mappings instead of deleting, store unmapped provider statuses
- Make mapped_status nullable so unmapping (X button) sets NULL instead of
  deleting the row — provider statuses never disappear from the UI
- normalize_series_status now returns the raw provider status (lowercased)
  when no mapping exists, so all statuses are stored in series_metadata
- Fix series_statuses query crash caused by NULL mapped_status values
- Fix metadata batch/refresh server actions crashing page on 400 errors
- StatusMappingDto.mapped_status is now string | null in the backoffice

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:22:31 +01:00
d4c48de780 chore: bump version to 1.9.1 2026-03-19 12:59:31 +01:00
8948f75d62 fix: ignore unknown provider statuses instead of storing them
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5s
normalize_series_status now returns None when no mapping exists,
so unknown provider statuses won't pollute series_metadata.status.
Users can see unmapped statuses in Settings and assign them before
they get stored.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:58:55 +01:00
d304877a83 fix: re-normalize series statuses with UI-added mappings
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
Migration 0041 re-applies status normalization using all current
status_mappings entries, including those added via the UI after the
initial migration 0039 (e.g. "one shot" → "ended").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:57:14 +01:00
9cec32ba3e fix: normalize series status casing to avoid duplicates
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
- LOWER() all series_metadata.status values in the statuses endpoint
  to prevent "One shot" / "one shot" appearing as separate targets
- Migration 0040: lowercase all existing status values in DB
- Use LOWER() in series status filter queries for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:56:02 +01:00
e8768dfad7 chore: bump version to 1.9.0 2026-03-19 12:44:30 +01:00
cfc98819ab feat: add configurable status mappings for metadata providers
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
Add a status_mappings table to replace hardcoded provider status
normalization. Users can now configure how provider statuses (e.g.
"releasing", "finie") map to target statuses (e.g. "ongoing", "ended")
via the Settings > Integrations page.

- Migration 0038: status_mappings table with pre-seeded mappings
- Migration 0039: re-normalize existing series_metadata.status values
- API: CRUD endpoints for status mappings, DB-based normalize function
- API: new GET /series/provider-statuses endpoint
- Backoffice: StatusMappingsCard component with create target, assign,
  and delete capabilities
- Fix all clippy warnings across the API crate
- Fix missing OpenAPI schema refs (MetadataStats, ProviderCount)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:44:22 +01:00
bfc1c76fe2 chore: repair deploy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 32s
2026-03-19 11:19:50 +01:00
39e9f35acb chore: push deploy stack local with dockerhub images
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 8s
2026-03-19 11:16:29 +01:00
36987f59b9 chore: bump version to 1.8.1 2026-03-19 11:12:06 +01:00
931d0e06f4 feat: redesign search bars with prominent search input and compact filters
Restructure LiveSearchForm: full-width search input with magnifying glass
icon, filters in a compact row below with contextual icons per field
(library, status, sort, etc.) and inline labels. Remove per-field
className overrides from series and books pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:12:00 +01:00
741a4da878 feat: redesign jobs page action bar with grouped layout
Replace flat button row + separate reference card with a single card
organized in 3 visual groups (Indexation, Thumbnails, Metadata).
Each action is a card-like button with inline description.
Destructive actions have distinct warning styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:03:08 +01:00
e28b78d0e6 chore: bump version to 1.8.0 2026-03-19 09:09:27 +01:00
163dc3698c feat: add metadata refresh job to re-download metadata for linked series
Adds a new job type that refreshes metadata from external providers for
all series already linked via approved external_metadata_links. Tracks
and displays per-field diffs (series and book level), respects locked
fields, and provides a detailed change report in the job detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:09:10 +01:00
818bd82e0f chore: bump version to 1.7.0 2026-03-18 22:26:15 +01:00
76c8bcbf2c chore: bump version to 1.6.5 2026-03-18 22:20:02 +01:00
00094b22c6 feat: add metadata statistics to dashboard
Add a new metadata row to the dashboard with three cards:
- Series metadata coverage (linked vs unlinked donut)
- Provider breakdown (donut by provider)
- Book metadata quality (summary and ISBN fill rates)

Includes API changes (stats.rs), frontend types, and FR/EN translations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:19:53 +01:00
1e4d9acebe fix: normalize French articles in Bedetheque confidence scoring
Bedetheque uses "Légendaires (Les) - Résistance" while local series
names are "Les légendaires - Résistance". Add normalize_title() that
strips leading articles and articles in parentheses before comparing,
so these forms correctly produce a 100% confidence match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:04:58 +01:00
b226aa3a35 chore: bump version to 1.6.4 2026-03-18 22:04:40 +01:00
d913be9d2a chore: bump version to 1.6.3 2026-03-18 21:44:37 +01:00
e9bb951d97 feat: auto-match metadata when 100% confidence and matching book count
When multiple provider results exist but the best has 100% confidence,
compare local book count with external total_volumes. If they match,
treat it as an auto-match and link+sync series and book metadata
automatically instead of requiring manual review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:44:31 +01:00
037ede2750 chore: bump version to 1.6.2 2026-03-18 21:36:44 +01:00
06a245d90a feat: add metadata provider filter to series page
- Add `metadata_provider` query param to series API endpoints (linked/unlinked/specific provider)
- Return `metadata_provider` field in series response
- Add metadata filter dropdown on series page with all provider options
- Show small provider icon badge on linked series cards
- LiveSearchForm now wraps filters on two rows when needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:35:38 +01:00
63d5fcaa13 chore: bump version to 1.6.1 2026-03-18 21:19:38 +01:00
020cb6baae feat: make series names clickable in batch metadata report
Links series names to their detail page where the metadata search
modal can be triggered for quick provider lookup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:07:21 +01:00
6db8042ffe chore: bump version to 1.6.0 2026-03-18 19:39:10 +01:00
d4f87c4044 feat: add i18n support (FR/EN) to backoffice with English as default
Implement full internationalization for the Next.js backoffice:
- i18n infrastructure: type-safe dictionaries (fr.ts/en.ts), cookie-based locale detection, React Context for client components, server-side translation helper
- Language selector in Settings page (General tab) with cookie + DB persistence
- All ~35 pages and components translated via t() / useTranslation()
- Default locale set to English, French available via settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 19:39:01 +01:00
055c376222 fix: complete French translation for settings page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 18:26:59 +01:00
1cc5d049ea chore: bump version to 1.5.6 2026-03-18 18:26:50 +01:00
b955c2697c feat: add batch metadata jobs, series filters, and translate backoffice to French
- Add metadata_batch job type with background processing via tokio::spawn
- Auto-apply metadata only when single result at 100% confidence
- Support primary + fallback provider per library, "none" to opt out
- Add batch report/results API endpoints and job detail UI
- Add series_status and has_missing filters to both series listing pages
- Add GET /series/statuses endpoint for dynamic filter options
- Normalize series_metadata status values (migration 0036)
- Hide ComicVine provider tab when no API key configured
- Translate entire backoffice UI from English to French

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 18:26:44 +01:00
9a8c1577af chore: bump version to 1.5.5 2026-03-18 16:11:08 +01:00
162 changed files with 16209 additions and 3489 deletions

View File

@@ -13,6 +13,12 @@
# Use this token for the first API calls before creating proper API tokens
API_BOOTSTRAP_TOKEN=change-me-in-production
# Backoffice admin credentials (required)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-in-production
# Secret for signing session JWTs (min 32 chars, required)
SESSION_SECRET=change-me-in-production-use-32-chars-min
# =============================================================================
# Service Configuration
# =============================================================================

View File

@@ -0,0 +1,17 @@
name: Deploy with Docker Compose
on:
push:
branches:
- main # adapte la branche que tu veux déployer
jobs:
deploy:
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
steps:
- name: Deploy stack
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
BUILDKIT_PROGRESS=plain cd /Users/julienfroidefond/Sites/docker-stack && docker pull julienfroidefond32/stripstream-backoffice && docker pull julienfroidefond32/stripstream-api && docker pull julienfroidefond32/stripstream-indexer && ./scripts/stack.sh up stripstream

25
Cargo.lock generated
View File

@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api"
version = "1.5.4"
version = "2.0.0"
dependencies = [
"anyhow",
"argon2",
@@ -76,6 +76,7 @@ dependencies = [
"image",
"jpeg-decoder",
"lru",
"notifications",
"parsers",
"rand 0.8.5",
"regex",
@@ -1232,7 +1233,7 @@ dependencies = [
[[package]]
name = "indexer"
version = "1.5.4"
version = "2.0.0"
dependencies = [
"anyhow",
"axum",
@@ -1240,6 +1241,7 @@ dependencies = [
"futures",
"image",
"jpeg-decoder",
"notifications",
"num_cpus",
"parsers",
"reqwest",
@@ -1663,6 +1665,19 @@ dependencies = [
"nom",
]
[[package]]
name = "notifications"
version = "2.0.0"
dependencies = [
"anyhow",
"reqwest",
"serde",
"serde_json",
"sqlx",
"tokio",
"tracing",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1771,7 +1786,7 @@ dependencies = [
[[package]]
name = "parsers"
version = "1.5.4"
version = "2.0.0"
dependencies = [
"anyhow",
"flate2",
@@ -2270,6 +2285,7 @@ dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -2278,6 +2294,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -2906,7 +2923,7 @@ dependencies = [
[[package]]
name = "stripstream-core"
version = "1.5.4"
version = "2.0.0"
dependencies = [
"anyhow",
"serde",

View File

@@ -3,13 +3,14 @@ members = [
"apps/api",
"apps/indexer",
"crates/core",
"crates/notifications",
"crates/parsers",
]
resolver = "2"
[workspace.package]
edition = "2021"
version = "1.5.4"
version = "2.0.0"
license = "MIT"
[workspace.dependencies]
@@ -22,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
jpeg-decoder = "0.3"
lru = "0.12"
rayon = "1.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Julien Froidefond
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -81,28 +81,66 @@ The backoffice will be available at http://localhost:7082
## Features
### Libraries Management
- Create and manage multiple libraries
- Configure automatic scanning schedules (hourly, daily, weekly)
- Real-time file watcher for instant indexing
- Full and incremental rebuild options
> For the full feature list, business rules, and API details, see [docs/FEATURES.md](docs/FEATURES.md).
### Books Management
- Support for CBZ, CBR, and PDF formats
- Automatic metadata extraction
- Series and volume detection
- Full-text search powered by PostgreSQL
### Libraries
- Multi-library management with per-library configuration
- Incremental and full scanning, real-time filesystem watcher
- Per-library metadata provider selection (Google Books, ComicVine, BedéThèque, AniList, Open Library)
### Jobs Monitoring
- Real-time job progress tracking
- Detailed statistics (scanned, indexed, removed, errors)
- Job history and logs
- Cancel pending jobs
### Books & Series
- **Formats**: CBZ, CBR, PDF, EPUB
- Automatic metadata extraction (title, series, volume, authors, page count) from filenames and directory structure
- Series aggregation with missing volume detection
- Thumbnail generation (WebP/JPEG/PNG) with lazy generation and bulk rebuild
- CBR → CBZ conversion
### Search
- Full-text search across titles, authors, and series
- Library filtering
- Real-time suggestions
### Reading Progress
- Per-book tracking: unread / reading / read with current page
- Series-level aggregated reading status
- Bulk mark-as-read for series
### Search & Discovery
- Full-text search across titles, authors, and series (PostgreSQL `pg_trgm`)
- Author listing with book/series counts
- Filtering by reading status, series status, format, metadata provider
### External Metadata
- Search, match, approve/reject workflow with confidence scoring
- Batch auto-matching and scheduled metadata refresh
- Field locking to protect manual edits from sync
### Notifications
- **Telegram**: real-time notifications via Telegram Bot API
- 12 granular event toggles (scans, thumbnails, conversions, metadata)
- Book thumbnail images included in notifications where applicable
- Test connection from settings
### External Integrations
- **Komga**: import reading progress
- **Prowlarr**: search for missing volumes
- **qBittorrent**: add torrents directly from search results
### Background Jobs
- Rebuild, rescan, thumbnail generation, metadata batch, CBR conversion
- Real-time progress via Server-Sent Events (SSE)
- Job history, error tracking, cancellation
### Page Rendering
- On-demand page extraction from all formats
- Image processing (format, quality, max width, resampling filter)
- LRU in-memory + disk cache
### Security
- Token-based auth (`admin` / `read` scopes) with Argon2 hashing
- Rate limiting, token expiration and revocation
### Web UI (Backoffice)
- Dashboard with statistics, interactive charts (recharts), and reading progress
- Currently reading & recently read sections
- Library, book, series, author management
- Live job monitoring, metadata search modals, settings panel
- Notification settings with per-event toggle configuration
## Environment Variables
@@ -249,4 +287,4 @@ volumes:
## License
[Your License Here]
This project is licensed under the [MIT License](LICENSE).

View File

@@ -15,6 +15,7 @@ futures = "0.3"
image.workspace = true
jpeg-decoder.workspace = true
lru.workspace = true
notifications = { path = "../../crates/notifications" }
stripstream-core = { path = "../../crates/core" }
parsers = { path = "../../crates/parsers" }
rand.workspace = true

View File

@@ -6,13 +6,15 @@ COPY Cargo.toml ./
COPY apps/api/Cargo.toml apps/api/Cargo.toml
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
COPY crates/core/Cargo.toml crates/core/Cargo.toml
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/parsers/src && \
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
echo "fn main() {}" > apps/api/src/main.rs && \
echo "fn main() {}" > apps/indexer/src/main.rs && \
echo "" > apps/indexer/src/lib.rs && \
echo "" > crates/core/src/lib.rs && \
echo "" > crates/notifications/src/lib.rs && \
echo "" > crates/parsers/src/lib.rs
# Build dependencies only (cached as long as Cargo.toml files don't change)
@@ -26,12 +28,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
COPY apps/api/src apps/api/src
COPY apps/indexer/src apps/indexer/src
COPY crates/core/src crates/core/src
COPY crates/notifications/src crates/notifications/src
COPY crates/parsers/src crates/parsers/src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
touch apps/api/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
touch apps/api/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
cargo build --release -p api && \
cp /app/target/release/api /usr/local/bin/api

View File

@@ -10,10 +10,15 @@ use sqlx::Row;
use crate::{error::ApiError, state::AppState};
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user_id: uuid::Uuid,
}
#[derive(Clone, Debug)]
pub enum Scope {
Admin,
Read,
Read { user_id: uuid::Uuid },
}
pub async fn require_admin(
@@ -40,6 +45,20 @@ pub async fn require_read(
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
let scope = authenticate(&state, token).await?;
if let Scope::Read { user_id } = &scope {
req.extensions_mut().insert(AuthUser { user_id: *user_id });
} else if matches!(scope, Scope::Admin) {
// Admin peut s'impersonifier via le header X-As-User
if let Some(as_user_id) = req
.headers()
.get("X-As-User")
.and_then(|v| v.to_str().ok())
.and_then(|v| uuid::Uuid::parse_str(v).ok())
{
req.extensions_mut().insert(AuthUser { user_id: as_user_id });
}
}
req.extensions_mut().insert(scope);
Ok(next.run(req).await)
}
@@ -60,8 +79,7 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
let maybe_row = sqlx::query(
r#"
SELECT id, token_hash, scope
FROM api_tokens
SELECT id, token_hash, scope, user_id FROM api_tokens
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
"#,
)
@@ -88,7 +106,12 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
match scope.as_str() {
"admin" => Ok(Scope::Admin),
"read" => Ok(Scope::Read),
"read" => {
let user_id: uuid::Uuid = row
.try_get("user_id")
.map_err(|_| ApiError::unauthorized("read token missing user_id"))?;
Ok(Scope::Read { user_id })
}
_ => Err(ApiError::unauthorized("invalid token scope")),
}
}

178
apps/api/src/authors.rs Normal file
View File

@@ -0,0 +1,178 @@
use axum::{extract::{Query, State}, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
#[derive(Deserialize, ToSchema)]
pub struct ListAuthorsQuery {
#[schema(value_type = Option<String>, example = "batman")]
pub q: Option<String>,
#[schema(value_type = Option<i64>, example = 1)]
pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 20)]
pub limit: Option<i64>,
/// Sort order: "name" (default), "books" (most books first)
#[schema(value_type = Option<String>, example = "books")]
pub sort: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct AuthorItem {
pub name: String,
pub book_count: i64,
pub series_count: i64,
}
#[derive(Serialize, ToSchema)]
pub struct AuthorsPageResponse {
pub items: Vec<AuthorItem>,
pub total: i64,
pub page: i64,
pub limit: i64,
}
/// List all unique authors with book/series counts
#[utoipa::path(
get,
path = "/authors",
tag = "authors",
params(
("q" = Option<String>, Query, description = "Search by author name"),
("page" = Option<i64>, Query, description = "Page number (1-based)"),
("limit" = Option<i64>, Query, description = "Items per page (max 100)"),
("sort" = Option<String>, Query, description = "Sort: name (default) or books"),
),
responses(
(status = 200, body = AuthorsPageResponse),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn list_authors(
State(state): State<AppState>,
Query(query): Query<ListAuthorsQuery>,
) -> Result<Json<AuthorsPageResponse>, ApiError> {
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let sort = query.sort.as_deref().unwrap_or("name");
let order_clause = match sort {
"books" => "book_count DESC, name ASC",
_ => "name ASC",
};
let q_pattern = query.q.as_deref()
.filter(|s| !s.trim().is_empty())
.map(|s| format!("%{s}%"));
// Aggregate unique authors from books.authors + books.author + series_metadata.authors
let sql = format!(
r#"
WITH all_authors AS (
SELECT DISTINCT UNNEST(
COALESCE(
NULLIF(authors, '{{}}'),
CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END
)
) AS name
FROM books
UNION
SELECT DISTINCT UNNEST(authors) AS name
FROM series_metadata
WHERE authors != '{{}}'
),
filtered AS (
SELECT name FROM all_authors
WHERE ($1::text IS NULL OR name ILIKE $1)
),
book_counts AS (
SELECT
f.name AS author_name,
COUNT(DISTINCT b.id) AS book_count
FROM filtered f
LEFT JOIN books b ON (
f.name = ANY(
COALESCE(
NULLIF(b.authors, '{{}}'),
CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END
)
)
)
GROUP BY f.name
),
series_counts AS (
SELECT
f.name AS author_name,
COUNT(DISTINCT (sm.library_id, sm.name)) AS series_count
FROM filtered f
LEFT JOIN series_metadata sm ON (
f.name = ANY(sm.authors) AND sm.authors != '{{}}'
)
GROUP BY f.name
)
SELECT
f.name,
COALESCE(bc.book_count, 0) AS book_count,
COALESCE(sc.series_count, 0) AS series_count
FROM filtered f
LEFT JOIN book_counts bc ON bc.author_name = f.name
LEFT JOIN series_counts sc ON sc.author_name = f.name
ORDER BY {order_clause}
LIMIT $2 OFFSET $3
"#
);
let count_sql = r#"
WITH all_authors AS (
SELECT DISTINCT UNNEST(
COALESCE(
NULLIF(authors, '{}'),
CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END
)
) AS name
FROM books
UNION
SELECT DISTINCT UNNEST(authors) AS name
FROM series_metadata
WHERE authors != '{}'
)
SELECT COUNT(*) AS total
FROM all_authors
WHERE ($1::text IS NULL OR name ILIKE $1)
"#;
let (rows, count_row) = tokio::join!(
sqlx::query(&sql)
.bind(q_pattern.as_deref())
.bind(limit)
.bind(offset)
.fetch_all(&state.pool),
sqlx::query(count_sql)
.bind(q_pattern.as_deref())
.fetch_one(&state.pool)
);
let rows = rows.map_err(|e| ApiError::internal(format!("authors query failed: {e}")))?;
let total: i64 = count_row
.map_err(|e| ApiError::internal(format!("authors count failed: {e}")))?
.get("total");
let items: Vec<AuthorItem> = rows
.iter()
.map(|r| AuthorItem {
name: r.get("name"),
book_count: r.get("book_count"),
series_count: r.get("series_count"),
})
.collect();
Ok(Json(AuthorsPageResponse {
items,
total,
page,
limit,
}))
}

View File

@@ -1,11 +1,11 @@
use axum::{extract::{Path, Query, State}, Json};
use axum::{extract::{Extension, Path, Query, State}, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, index_jobs::IndexJobResponse, state::AppState};
use crate::{auth::AuthUser, error::ApiError, index_jobs::IndexJobResponse, state::AppState};
#[derive(Deserialize, ToSchema)]
pub struct ListBooksQuery {
@@ -19,6 +19,9 @@ pub struct ListBooksQuery {
pub series: Option<String>,
#[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>,
/// Filter by exact author name (matches in authors array or scalar author field)
#[schema(value_type = Option<String>)]
pub author: Option<String>,
#[schema(value_type = Option<i64>, example = 1)]
pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 50)]
@@ -26,6 +29,9 @@ pub struct ListBooksQuery {
/// Sort order: "title" (default) or "latest" (most recently added first)
#[schema(value_type = Option<String>, example = "latest")]
pub sort: Option<String>,
/// Filter by metadata provider: "linked" (any provider), "unlinked" (no provider), or a specific provider name
#[schema(value_type = Option<String>, example = "linked")]
pub metadata_provider: Option<String>,
}
#[derive(Serialize, ToSchema)]
@@ -99,12 +105,13 @@ pub struct BookDetails {
tag = "books",
params(
("library_id" = Option<String>, Query, description = "Filter by library ID"),
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"),
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"),
("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"),
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: 'linked' (any provider), 'unlinked' (no provider), or a specific provider name"),
),
responses(
(status = 200, body = BooksPage),
@@ -115,7 +122,9 @@ pub struct BookDetails {
pub async fn list_books(
State(state): State<AppState>,
Query(query): Query<ListBooksQuery>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<BooksPage>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
@@ -135,15 +144,39 @@ pub async fn list_books(
let rs_cond = if reading_statuses.is_some() {
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
} else { String::new() };
let author_cond = if query.author.is_some() {
p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)) OR EXISTS (SELECT 1 FROM series_metadata sm WHERE sm.library_id = b.library_id AND sm.name = b.series AND ${p} = ANY(sm.authors)))")
} else { String::new() };
let metadata_cond = match query.metadata_provider.as_deref() {
Some("unlinked") => "AND eml.id IS NULL".to_string(),
Some("linked") => "AND eml.id IS NOT NULL".to_string(),
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
None => String::new(),
};
p += 1;
let uid_p = p;
let metadata_links_cte = r#"
metadata_links AS (
SELECT DISTINCT ON (eml.series_name, eml.library_id)
eml.series_name, eml.library_id, eml.provider, eml.id
FROM external_metadata_links eml
WHERE eml.status = 'approved'
ORDER BY eml.series_name, eml.library_id, eml.created_at DESC
)"#;
let count_sql = format!(
r#"SELECT COUNT(*) FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
r#"WITH {metadata_links_cte}
SELECT COUNT(*) FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
AND ($3::text IS NULL OR b.format = $3)
{series_cond}
{rs_cond}"#
{rs_cond}
{author_cond}
{metadata_cond}"#
);
let order_clause = if query.sort.as_deref() == Some("latest") {
@@ -157,17 +190,21 @@ pub async fn list_books(
let offset_p = p + 2;
let data_sql = format!(
r#"
WITH {metadata_links_cte}
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
AND ($3::text IS NULL OR b.format = $3)
{series_cond}
{rs_cond}
{author_cond}
{metadata_cond}
ORDER BY {order_clause}
LIMIT ${limit_p} OFFSET ${offset_p}
"#
@@ -192,8 +229,18 @@ pub async fn list_books(
count_builder = count_builder.bind(statuses.clone());
data_builder = data_builder.bind(statuses.clone());
}
data_builder = data_builder.bind(limit).bind(offset);
if let Some(ref author) = query.author {
count_builder = count_builder.bind(author.clone());
data_builder = data_builder.bind(author.clone());
}
if let Some(ref mp) = query.metadata_provider {
if mp != "linked" && mp != "unlinked" {
count_builder = count_builder.bind(mp.clone());
data_builder = data_builder.bind(mp.clone());
}
}
count_builder = count_builder.bind(user_id);
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
@@ -252,7 +299,9 @@ pub async fn list_books(
pub async fn get_book(
State(state): State<AppState>,
Path(id): Path<Uuid>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<BookDetails>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let row = sqlx::query(
r#"
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
@@ -268,11 +317,12 @@ pub async fn get_book(
ORDER BY updated_at DESC
LIMIT 1
) bf ON TRUE
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
WHERE b.id = $1
"#,
)
.bind(id)
.bind(user_id)
.fetch_optional(&state.pool)
.await?;
@@ -303,557 +353,9 @@ pub async fn get_book(
}))
}
#[derive(Serialize, ToSchema)]
pub struct SeriesItem {
pub name: String,
pub book_count: i64,
pub books_read_count: i64,
#[schema(value_type = String)]
pub first_book_id: Uuid,
#[schema(value_type = String)]
pub library_id: Uuid,
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
#[derive(Serialize, ToSchema)]
pub struct SeriesPage {
pub items: Vec<SeriesItem>,
pub total: i64,
pub page: i64,
pub limit: i64,
}
#[derive(Deserialize, ToSchema)]
pub struct ListSeriesQuery {
#[schema(value_type = Option<String>, example = "dragon")]
pub q: Option<String>,
#[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>,
#[schema(value_type = Option<i64>, example = 1)]
pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 50)]
pub limit: Option<i64>,
}
/// List all series in a library with pagination
#[utoipa::path(
get,
path = "/libraries/{library_id}/series",
tag = "books",
params(
("library_id" = String, Path, description = "Library UUID"),
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
),
responses(
(status = 200, body = SeriesPage),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn list_series(
State(state): State<AppState>,
Path(library_id): Path<Uuid>,
Query(query): Query<ListSeriesQuery>,
) -> Result<Json<SeriesPage>, ApiError> {
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
});
let series_status_expr = r#"CASE
WHEN sc.books_read_count = sc.book_count THEN 'read'
WHEN sc.books_read_count = 0 THEN 'unread'
ELSE 'reading'
END"#;
// Paramètres dynamiques — $1 = library_id fixe, puis optionnels dans l'ordre
let mut p: usize = 1;
let q_cond = if query.q.is_some() {
p += 1; format!("AND sc.name ILIKE ${p}")
} else { String::new() };
let count_rs_cond = if reading_statuses.is_some() {
p += 1; format!("AND {series_status_expr} = ANY(${p})")
} else { String::new() };
// q_cond et count_rs_cond partagent le même p — le count_sql les réutilise directement
let count_sql = format!(
r#"
WITH sorted_books AS (
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
FROM books WHERE library_id = $1
),
series_counts AS (
SELECT sb.name,
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {count_rs_cond}
"#
);
// DATA: mêmes params dans le même ordre, puis limit/offset à la fin
let limit_p = p + 1;
let offset_p = p + 2;
let data_sql = format!(
r#"
WITH sorted_books AS (
SELECT
COALESCE(NULLIF(series, ''), 'unclassified') as name,
id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
ORDER BY
volume NULLS LAST,
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
title ASC
) as rn
FROM books
WHERE library_id = $1
),
series_counts AS (
SELECT
sb.name,
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
SELECT
sc.name,
sc.book_count,
sc.books_read_count,
sb.id as first_book_id
FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
WHERE TRUE
{q_cond}
{count_rs_cond}
ORDER BY
REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''),
COALESCE(
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
0
),
sc.name ASC
LIMIT ${limit_p} OFFSET ${offset_p}
"#
);
let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
let mut count_builder = sqlx::query(&count_sql).bind(library_id);
let mut data_builder = sqlx::query(&data_sql).bind(library_id);
if let Some(ref pat) = q_pattern {
count_builder = count_builder.bind(pat);
data_builder = data_builder.bind(pat);
}
if let Some(ref statuses) = reading_statuses {
count_builder = count_builder.bind(statuses.clone());
data_builder = data_builder.bind(statuses.clone());
}
data_builder = data_builder.bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
data_builder.fetch_all(&state.pool),
)?;
let total: i64 = count_row.get(0);
let mut items: Vec<SeriesItem> = rows
.iter()
.map(|row| SeriesItem {
name: row.get("name"),
book_count: row.get("book_count"),
books_read_count: row.get("books_read_count"),
first_book_id: row.get("first_book_id"),
library_id,
})
.collect();
Ok(Json(SeriesPage {
items: std::mem::take(&mut items),
total,
page,
limit,
}))
}
#[derive(Deserialize, ToSchema)]
pub struct ListAllSeriesQuery {
#[schema(value_type = Option<String>, example = "dragon")]
pub q: Option<String>,
#[schema(value_type = Option<String>)]
pub library_id: Option<Uuid>,
#[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>,
#[schema(value_type = Option<i64>, example = 1)]
pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 50)]
pub limit: Option<i64>,
/// Sort order: "title" (default) or "latest" (most recently added first)
#[schema(value_type = Option<String>, example = "latest")]
pub sort: Option<String>,
}
/// List all series across libraries with optional filtering and pagination
#[utoipa::path(
get,
path = "/series",
tag = "books",
params(
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
("library_id" = Option<String>, Query, description = "Filter by library ID"),
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
),
responses(
(status = 200, body = SeriesPage),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn list_all_series(
State(state): State<AppState>,
Query(query): Query<ListAllSeriesQuery>,
) -> Result<Json<SeriesPage>, ApiError> {
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
});
let series_status_expr = r#"CASE
WHEN sc.books_read_count = sc.book_count THEN 'read'
WHEN sc.books_read_count = 0 THEN 'unread'
ELSE 'reading'
END"#;
let mut p: usize = 0;
let lib_cond = if query.library_id.is_some() {
p += 1; format!("WHERE library_id = ${p}")
} else {
"WHERE TRUE".to_string()
};
let q_cond = if query.q.is_some() {
p += 1; format!("AND sc.name ILIKE ${p}")
} else { String::new() };
let rs_cond = if reading_statuses.is_some() {
p += 1; format!("AND {series_status_expr} = ANY(${p})")
} else { String::new() };
let count_sql = format!(
r#"
WITH sorted_books AS (
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
FROM books {lib_cond}
),
series_counts AS (
SELECT sb.name,
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {rs_cond}
"#
);
let series_order_clause = if query.sort.as_deref() == Some("latest") {
"sc.latest_updated_at DESC".to_string()
} else {
"REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(sc.name), '\\d+'))[1]::int, 0), sc.name ASC".to_string()
};
let limit_p = p + 1;
let offset_p = p + 2;
let data_sql = format!(
r#"
WITH sorted_books AS (
SELECT
COALESCE(NULLIF(series, ''), 'unclassified') as name,
id,
library_id,
updated_at,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
ORDER BY
volume NULLS LAST,
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
title ASC
) as rn
FROM books
{lib_cond}
),
series_counts AS (
SELECT
sb.name,
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
MAX(sb.updated_at) as latest_updated_at
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
SELECT
sc.name,
sc.book_count,
sc.books_read_count,
sb.id as first_book_id,
sb.library_id
FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
WHERE TRUE
{q_cond}
{rs_cond}
ORDER BY {series_order_clause}
LIMIT ${limit_p} OFFSET ${offset_p}
"#
);
let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
let mut count_builder = sqlx::query(&count_sql);
let mut data_builder = sqlx::query(&data_sql);
if let Some(lib_id) = query.library_id {
count_builder = count_builder.bind(lib_id);
data_builder = data_builder.bind(lib_id);
}
if let Some(ref pat) = q_pattern {
count_builder = count_builder.bind(pat);
data_builder = data_builder.bind(pat);
}
if let Some(ref statuses) = reading_statuses {
count_builder = count_builder.bind(statuses.clone());
data_builder = data_builder.bind(statuses.clone());
}
data_builder = data_builder.bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
data_builder.fetch_all(&state.pool),
)?;
let total: i64 = count_row.get(0);
let items: Vec<SeriesItem> = rows
.iter()
.map(|row| SeriesItem {
name: row.get("name"),
book_count: row.get("book_count"),
books_read_count: row.get("books_read_count"),
first_book_id: row.get("first_book_id"),
library_id: row.get("library_id"),
})
.collect();
Ok(Json(SeriesPage {
items,
total,
page,
limit,
}))
}
#[derive(Deserialize, ToSchema)]
pub struct OngoingQuery {
#[schema(value_type = Option<i64>, example = 10)]
pub limit: Option<i64>,
}
/// List ongoing series (partially read, sorted by most recent activity)
#[utoipa::path(
get,
path = "/series/ongoing",
tag = "books",
params(
("limit" = Option<i64>, Query, description = "Max items to return (default 10, max 50)"),
),
responses(
(status = 200, body = Vec<SeriesItem>),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn ongoing_series(
State(state): State<AppState>,
Query(query): Query<OngoingQuery>,
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
let limit = query.limit.unwrap_or(10).clamp(1, 50);
let rows = sqlx::query(
r#"
WITH series_stats AS (
SELECT
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
COUNT(*) AS book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count,
MAX(brp.last_read_at) AS last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
HAVING (
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
AND COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') < COUNT(*)
)
),
first_books AS (
SELECT
COALESCE(NULLIF(series, ''), 'unclassified') AS name,
id,
library_id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
ORDER BY
volume NULLS LAST,
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
title ASC
) AS rn
FROM books
)
SELECT ss.name, ss.book_count, ss.books_read_count, fb.id AS first_book_id, fb.library_id
FROM series_stats ss
JOIN first_books fb ON fb.name = ss.name AND fb.rn = 1
ORDER BY ss.last_read_at DESC NULLS LAST
LIMIT $1
"#,
)
.bind(limit)
.fetch_all(&state.pool)
.await?;
let items: Vec<SeriesItem> = rows
.iter()
.map(|row| SeriesItem {
name: row.get("name"),
book_count: row.get("book_count"),
books_read_count: row.get("books_read_count"),
first_book_id: row.get("first_book_id"),
library_id: row.get("library_id"),
})
.collect();
Ok(Json(items))
}
/// List next unread book for each ongoing series (sorted by most recent activity)
#[utoipa::path(
get,
path = "/books/ongoing",
tag = "books",
params(
("limit" = Option<i64>, Query, description = "Max items to return (default 10, max 50)"),
),
responses(
(status = 200, body = Vec<BookItem>),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn ongoing_books(
State(state): State<AppState>,
Query(query): Query<OngoingQuery>,
) -> Result<Json<Vec<BookItem>>, ApiError> {
let limit = query.limit.unwrap_or(10).clamp(1, 50);
let rows = sqlx::query(
r#"
WITH ongoing_series AS (
SELECT
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
MAX(brp.last_read_at) AS series_last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
HAVING (
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
AND COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') < COUNT(*)
)
),
next_books AS (
SELECT
b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume,
b.language, b.page_count, b.thumbnail_path, b.updated_at,
COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at,
os.series_last_read_at,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(b.series, ''), 'unclassified')
ORDER BY b.volume NULLS LAST, b.title
) AS rn
FROM books b
JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
WHERE COALESCE(brp.status, 'unread') != 'read'
)
SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count,
thumbnail_path, updated_at, reading_status, reading_current_page, reading_last_read_at
FROM next_books
WHERE rn = 1
ORDER BY series_last_read_at DESC NULLS LAST
LIMIT $1
"#,
)
.bind(limit)
.fetch_all(&state.pool)
.await?;
let items: Vec<BookItem> = rows
.iter()
.map(|row| {
let thumbnail_path: Option<String> = row.get("thumbnail_path");
BookItem {
id: row.get("id"),
library_id: row.get("library_id"),
kind: row.get("kind"),
format: row.get("format"),
title: row.get("title"),
author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"),
volume: row.get("volume"),
language: row.get("language"),
page_count: row.get("page_count"),
thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", row.get::<Uuid, _>("id"))),
updated_at: row.get("updated_at"),
reading_status: row.get("reading_status"),
reading_current_page: row.get("reading_current_page"),
reading_last_read_at: row.get("reading_last_read_at"),
}
})
.collect();
Ok(Json(items))
}
fn remap_libraries_path(path: &str) -> String {
pub(crate) fn remap_libraries_path(path: &str) -> String {
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
if path.starts_with("/libraries/") {
return path.replacen("/libraries", &root, 1);
@@ -871,6 +373,8 @@ fn unmap_libraries_path(path: &str) -> String {
path.to_string()
}
// ─── Convert CBR → CBZ ───────────────────────────────────────────────────────
/// Enqueue a CBR → CBZ conversion job for a single book
#[utoipa::path(
post,
@@ -1024,9 +528,9 @@ pub async fn update_book(
WHERE id = $1
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
summary, isbn, publish_date,
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status,
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page,
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
'unread' AS reading_status,
NULL::integer AS reading_current_page,
NULL::timestamptz AS reading_last_read_at
"#,
)
.bind(id)
@@ -1071,234 +575,7 @@ pub async fn update_book(
}))
}
#[derive(Serialize, ToSchema)]
pub struct SeriesMetadata {
/// Authors of the series (series-level metadata, distinct from per-book author field)
pub authors: Vec<String>,
pub description: Option<String>,
pub publishers: Vec<String>,
pub start_year: Option<i32>,
pub total_volumes: Option<i32>,
/// Series status: "ongoing", "ended", "hiatus", "cancelled", or null
pub status: Option<String>,
/// Convenience: author from first book (for pre-filling the per-book apply section)
pub book_author: Option<String>,
pub book_language: Option<String>,
/// Fields locked from external metadata sync, e.g. {"authors": true, "description": true}
pub locked_fields: serde_json::Value,
}
/// Get metadata for a specific series
#[utoipa::path(
get,
path = "/libraries/{library_id}/series/{name}/metadata",
tag = "books",
params(
("library_id" = String, Path, description = "Library UUID"),
("name" = String, Path, description = "Series name"),
),
responses(
(status = 200, body = SeriesMetadata),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_series_metadata(
State(state): State<AppState>,
Path((library_id, name)): Path<(Uuid, String)>,
) -> Result<Json<SeriesMetadata>, ApiError> {
// author/language from first book of series
let books_row = if name == "unclassified" {
sqlx::query("SELECT author, language FROM books WHERE library_id = $1 AND (series IS NULL OR series = '') LIMIT 1")
.bind(library_id)
.fetch_optional(&state.pool)
.await?
} else {
sqlx::query("SELECT author, language FROM books WHERE library_id = $1 AND series = $2 LIMIT 1")
.bind(library_id)
.bind(&name)
.fetch_optional(&state.pool)
.await?
};
let meta_row = sqlx::query(
"SELECT authors, description, publishers, start_year, total_volumes, status, locked_fields FROM series_metadata WHERE library_id = $1 AND name = $2"
)
.bind(library_id)
.bind(&name)
.fetch_optional(&state.pool)
.await?;
Ok(Json(SeriesMetadata {
authors: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("authors")).unwrap_or_default(),
description: meta_row.as_ref().and_then(|r| r.get("description")),
publishers: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("publishers")).unwrap_or_default(),
start_year: meta_row.as_ref().and_then(|r| r.get("start_year")),
total_volumes: meta_row.as_ref().and_then(|r| r.get("total_volumes")),
status: meta_row.as_ref().and_then(|r| r.get("status")),
book_author: books_row.as_ref().and_then(|r| r.get("author")),
book_language: books_row.as_ref().and_then(|r| r.get("language")),
locked_fields: meta_row.as_ref().map(|r| r.get::<serde_json::Value, _>("locked_fields")).unwrap_or(serde_json::json!({})),
}))
}
/// `author` and `language` are wrapped in an extra Option so we can distinguish
/// "absent from JSON" (keep books unchanged) from "present as null" (clear the field).
#[derive(Deserialize, ToSchema)]
pub struct UpdateSeriesRequest {
pub new_name: String,
/// Series-level authors list (stored in series_metadata)
#[serde(default)]
pub authors: Vec<String>,
/// Per-book author propagation: absent = keep books unchanged, present = overwrite all books
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<Option<String>>,
/// Per-book language propagation: absent = keep books unchanged, present = overwrite all books
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<Option<String>>,
pub description: Option<String>,
#[serde(default)]
pub publishers: Vec<String>,
pub start_year: Option<i32>,
pub total_volumes: Option<i32>,
/// Series status: "ongoing", "ended", "hiatus", "cancelled", or null
pub status: Option<String>,
/// Fields locked from external metadata sync
#[serde(default)]
pub locked_fields: Option<serde_json::Value>,
}
#[derive(Serialize, ToSchema)]
pub struct UpdateSeriesResponse {
pub updated: u64,
}
/// Update metadata for all books in a series
#[utoipa::path(
patch,
path = "/libraries/{library_id}/series/{name}",
tag = "books",
params(
("library_id" = String, Path, description = "Library UUID"),
("name" = String, Path, description = "Series name (use 'unclassified' for books without series)"),
),
request_body = UpdateSeriesRequest,
responses(
(status = 200, body = UpdateSeriesResponse),
(status = 400, description = "Invalid request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_series(
State(state): State<AppState>,
Path((library_id, name)): Path<(Uuid, String)>,
Json(body): Json<UpdateSeriesRequest>,
) -> Result<Json<UpdateSeriesResponse>, ApiError> {
let new_name = body.new_name.trim().to_string();
if new_name.is_empty() {
return Err(ApiError::bad_request("series name cannot be empty"));
}
// author/language: None = absent (keep books unchanged), Some(v) = apply to all books
let apply_author = body.author.is_some();
let author_value = body.author.flatten().as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let apply_language = body.language.is_some();
let language_value = body.language.flatten().as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let description = body.description.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let publishers: Vec<String> = body.publishers.iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
let new_series_value: Option<String> = if new_name == "unclassified" { None } else { Some(new_name.clone()) };
// 1. Update books: always update series name; author/language only if opted-in
// $1=library_id, $2=new_series_value, $3=apply_author, $4=author_value,
// $5=apply_language, $6=language_value, [$7=old_name]
let result = if name == "unclassified" {
sqlx::query(
"UPDATE books \
SET series = $2, \
author = CASE WHEN $3 THEN $4 ELSE author END, \
language = CASE WHEN $5 THEN $6 ELSE language END, \
updated_at = NOW() \
WHERE library_id = $1 AND (series IS NULL OR series = '')"
)
.bind(library_id)
.bind(&new_series_value)
.bind(apply_author)
.bind(&author_value)
.bind(apply_language)
.bind(&language_value)
.execute(&state.pool)
.await?
} else {
sqlx::query(
"UPDATE books \
SET series = $2, \
author = CASE WHEN $3 THEN $4 ELSE author END, \
language = CASE WHEN $5 THEN $6 ELSE language END, \
updated_at = NOW() \
WHERE library_id = $1 AND series = $7"
)
.bind(library_id)
.bind(&new_series_value)
.bind(apply_author)
.bind(&author_value)
.bind(apply_language)
.bind(&language_value)
.bind(&name)
.execute(&state.pool)
.await?
};
// 2. Upsert series_metadata (keyed by new_name)
let meta_name = new_series_value.as_deref().unwrap_or("unclassified");
let authors: Vec<String> = body.authors.iter()
.map(|a| a.trim().to_string())
.filter(|a| !a.is_empty())
.collect();
let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({}));
sqlx::query(
r#"
INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, total_volumes, status, locked_fields, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
ON CONFLICT (library_id, name) DO UPDATE
SET authors = EXCLUDED.authors,
description = EXCLUDED.description,
publishers = EXCLUDED.publishers,
start_year = EXCLUDED.start_year,
total_volumes = EXCLUDED.total_volumes,
status = EXCLUDED.status,
locked_fields = EXCLUDED.locked_fields,
updated_at = NOW()
"#
)
.bind(library_id)
.bind(meta_name)
.bind(&authors)
.bind(&description)
.bind(&publishers)
.bind(body.start_year)
.bind(body.total_volumes)
.bind(&body.status)
.bind(&locked_fields)
.execute(&state.pool)
.await?;
// 3. If renamed, move series_metadata from old name to new name
if name != "unclassified" && new_name != name {
sqlx::query(
"DELETE FROM series_metadata WHERE library_id = $1 AND name = $2"
)
.bind(library_id)
.bind(&name)
.execute(&state.pool)
.await?;
}
Ok(Json(UpdateSeriesResponse { updated: result.rows_affected() }))
}
// ─── Thumbnail ────────────────────────────────────────────────────────────────
use axum::{
body::Body,
@@ -1361,12 +638,17 @@ pub async fn get_thumbnail(
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
};
let etag_value = format!("\"{}_{:x}\"", book_id, data.len());
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
if let Ok(v) = HeaderValue::from_str(&etag_value) {
headers.insert(header::ETAG, v);
}
Ok((StatusCode::OK, headers, Body::from(data)))
}

View File

@@ -16,6 +16,10 @@ pub struct RebuildRequest {
pub library_id: Option<Uuid>,
#[schema(value_type = Option<bool>, example = false)]
pub full: Option<bool>,
/// Deep rescan: clears directory mtimes to force re-walking all directories,
/// discovering newly supported formats without deleting existing data.
#[schema(value_type = Option<bool>, example = false)]
pub rescan: Option<bool>,
}
#[derive(Serialize, ToSchema)]
@@ -117,7 +121,8 @@ pub async fn enqueue_rebuild(
) -> Result<Json<IndexJobResponse>, ApiError> {
let library_id = payload.as_ref().and_then(|p| p.0.library_id);
let is_full = payload.as_ref().and_then(|p| p.0.full).unwrap_or(false);
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
let is_rescan = payload.as_ref().and_then(|p| p.0.rescan).unwrap_or(false);
let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" };
let id = Uuid::new_v4();
sqlx::query(

134
apps/api/src/job_poller.rs Normal file
View File

@@ -0,0 +1,134 @@
use std::time::Duration;
use sqlx::{PgPool, Row};
use tracing::{error, info, trace};
use uuid::Uuid;
use crate::{metadata_batch, metadata_refresh};
/// Poll for pending API-only jobs (`metadata_batch`, `metadata_refresh`) and process them.
/// This mirrors the indexer's worker loop but for job types handled by the API.
pub async fn run_job_poller(pool: PgPool, interval_seconds: u64) {
let wait = Duration::from_secs(interval_seconds.max(1));
loop {
match claim_next_api_job(&pool).await {
Ok(Some((job_id, job_type, library_id))) => {
info!("[JOB_POLLER] Claimed {job_type} job {job_id} library={library_id}");
let pool_clone = pool.clone();
let library_name: Option<String> =
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&pool)
.await
.ok()
.flatten();
tokio::spawn(async move {
let result = match job_type.as_str() {
"metadata_refresh" => {
metadata_refresh::process_metadata_refresh(
&pool_clone,
job_id,
library_id,
)
.await
}
"metadata_batch" => {
metadata_batch::process_metadata_batch(
&pool_clone,
job_id,
library_id,
)
.await
}
_ => Err(format!("Unknown API job type: {job_type}")),
};
if let Err(e) = result {
error!("[JOB_POLLER] {job_type} job {job_id} failed: {e}");
let _ = sqlx::query(
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
)
.bind(job_id)
.bind(e.to_string())
.execute(&pool_clone)
.await;
match job_type.as_str() {
"metadata_refresh" => {
notifications::notify(
pool_clone,
notifications::NotificationEvent::MetadataRefreshFailed {
library_name,
error: e.to_string(),
},
);
}
"metadata_batch" => {
notifications::notify(
pool_clone,
notifications::NotificationEvent::MetadataBatchFailed {
library_name,
error: e.to_string(),
},
);
}
_ => {}
}
}
});
}
Ok(None) => {
trace!("[JOB_POLLER] No pending API jobs, waiting...");
tokio::time::sleep(wait).await;
}
Err(err) => {
error!("[JOB_POLLER] Error claiming job: {err}");
tokio::time::sleep(wait).await;
}
}
}
}
const API_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"];
async fn claim_next_api_job(pool: &PgPool) -> Result<Option<(Uuid, String, Uuid)>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row = sqlx::query(
r#"
SELECT id, type, library_id
FROM index_jobs
WHERE status = 'pending'
AND type = ANY($1)
AND library_id IS NOT NULL
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
"#,
)
.bind(API_JOB_TYPES)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let id: Uuid = row.get("id");
let job_type: String = row.get("type");
let library_id: Uuid = row.get("library_id");
sqlx::query(
"UPDATE index_jobs SET status = 'running', started_at = NOW(), error_opt = NULL WHERE id = $1",
)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((id, job_type, library_id)))
}

View File

@@ -38,6 +38,8 @@ pub struct KomgaSyncRequest {
pub url: String,
pub username: String,
pub password: String,
#[schema(value_type = String)]
pub user_id: Uuid,
}
#[derive(Serialize, ToSchema)]
@@ -45,6 +47,8 @@ pub struct KomgaSyncResponse {
#[schema(value_type = String)]
pub id: Uuid,
pub komga_url: String,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub total_komga_read: i64,
pub matched: i64,
pub already_read: i64,
@@ -61,6 +65,8 @@ pub struct KomgaSyncReportSummary {
#[schema(value_type = String)]
pub id: Uuid,
pub komga_url: String,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub total_komga_read: i64,
pub matched: i64,
pub already_read: i64,
@@ -154,10 +160,11 @@ pub async fn sync_komga_read_books(
.fetch_all(&state.pool)
.await?;
type BookEntry = (Uuid, String, String);
// Primary: (series_lower, title_lower) -> Vec<(Uuid, title, series)>
let mut primary_map: HashMap<(String, String), Vec<(Uuid, String, String)>> = HashMap::new();
let mut primary_map: HashMap<(String, String), Vec<BookEntry>> = HashMap::new();
// Secondary: title_lower -> Vec<(Uuid, title, series)>
let mut secondary_map: HashMap<String, Vec<(Uuid, String, String)>> = HashMap::new();
let mut secondary_map: HashMap<String, Vec<BookEntry>> = HashMap::new();
for row in &rows {
let id: Uuid = row.get("id");
@@ -214,11 +221,12 @@ pub async fn sync_komga_read_books(
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
if !matched_ids.is_empty() {
// Get already-read book IDs
// Get already-read book IDs for this user
let ar_rows = sqlx::query(
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'",
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND user_id = $2 AND status = 'read'",
)
.bind(&matched_ids)
.bind(body.user_id)
.fetch_all(&state.pool)
.await?;
@@ -227,12 +235,12 @@ pub async fn sync_komga_read_books(
}
already_read = already_read_ids.len() as i64;
// Bulk upsert all matched books as read
// Bulk upsert all matched books as read for this user
sqlx::query(
r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
SELECT unnest($1::uuid[]), 'read', NULL, NOW(), NOW()
ON CONFLICT (book_id) DO UPDATE
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT unnest($1::uuid[]), $2, 'read', NULL, NOW(), NOW()
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read',
current_page = NULL,
last_read_at = NOW(),
@@ -241,6 +249,7 @@ pub async fn sync_komga_read_books(
"#,
)
.bind(&matched_ids)
.bind(body.user_id)
.execute(&state.pool)
.await?;
}
@@ -272,12 +281,13 @@ pub async fn sync_komga_read_books(
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
let report_row = sqlx::query(
r#"
INSERT INTO komga_sync_reports (komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO komga_sync_reports (komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at
"#,
)
.bind(&url)
.bind(body.user_id)
.bind(total_komga_read)
.bind(matched)
.bind(already_read)
@@ -291,6 +301,7 @@ pub async fn sync_komga_read_books(
Ok(Json(KomgaSyncResponse {
id: report_row.get("id"),
komga_url: url,
user_id: Some(body.user_id),
total_komga_read,
matched,
already_read,
@@ -318,7 +329,7 @@ pub async fn list_sync_reports(
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
let rows = sqlx::query(
r#"
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked,
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked,
jsonb_array_length(unmatched) as unmatched_count, created_at
FROM komga_sync_reports
ORDER BY created_at DESC
@@ -333,6 +344,7 @@ pub async fn list_sync_reports(
.map(|row| KomgaSyncReportSummary {
id: row.get("id"),
komga_url: row.get("komga_url"),
user_id: row.get("user_id"),
total_komga_read: row.get("total_komga_read"),
matched: row.get("matched"),
already_read: row.get("already_read"),
@@ -364,7 +376,7 @@ pub async fn get_sync_report(
) -> Result<Json<KomgaSyncResponse>, ApiError> {
let row = sqlx::query(
r#"
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
FROM komga_sync_reports
WHERE id = $1
"#,
@@ -385,6 +397,7 @@ pub async fn get_sync_report(
Ok(Json(KomgaSyncResponse {
id: row.get("id"),
komga_url: row.get("komga_url"),
user_id: row.get("user_id"),
total_komga_read: row.get("total_komga_read"),
matched: row.get("matched"),
already_read: row.get("already_read"),

View File

@@ -22,6 +22,14 @@ pub struct LibraryResponse {
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
pub watcher_enabled: bool,
pub metadata_provider: Option<String>,
pub fallback_metadata_provider: Option<String>,
pub metadata_refresh_mode: String,
#[schema(value_type = Option<String>)]
pub next_metadata_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
pub series_count: i64,
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
#[schema(value_type = Vec<String>)]
pub thumbnail_book_ids: Vec<Uuid>,
}
#[derive(Deserialize, ToSchema)]
@@ -40,14 +48,27 @@ pub struct CreateLibraryRequest {
responses(
(status = 200, body = Vec<LibraryResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider,
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at,
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count,
(SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
COALESCE((
SELECT ARRAY_AGG(first_id ORDER BY series_name)
FROM (
SELECT DISTINCT ON (COALESCE(NULLIF(b.series, ''), 'unclassified'))
COALESCE(NULLIF(b.series, ''), 'unclassified') as series_name,
b.id as first_id
FROM books b
WHERE b.library_id = l.id
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'),
b.volume NULLS LAST, b.title ASC
LIMIT 5
) sub
), ARRAY[]::uuid[]) as thumbnail_book_ids
FROM libraries l ORDER BY l.created_at DESC"
)
.fetch_all(&state.pool)
@@ -61,11 +82,16 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count: row.get("book_count"),
series_count: row.get("series_count"),
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
watcher_enabled: row.get("watcher_enabled"),
metadata_provider: row.get("metadata_provider"),
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids: row.get("thumbnail_book_ids"),
})
.collect();
@@ -113,11 +139,16 @@ pub async fn create_library(
root_path,
enabled: true,
book_count: 0,
series_count: 0,
monitor_enabled: false,
scan_mode: "manual".to_string(),
next_scan_at: None,
watcher_enabled: false,
metadata_provider: None,
fallback_metadata_provider: None,
metadata_refresh_mode: "manual".to_string(),
next_metadata_refresh_at: None,
thumbnail_book_ids: vec![],
}))
}
@@ -189,7 +220,6 @@ use crate::index_jobs::{IndexJobResponse, RebuildRequest};
(status = 200, body = IndexJobResponse),
(status = 404, description = "Library not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
@@ -209,7 +239,8 @@ pub async fn scan_library(
}
let is_full = payload.as_ref().and_then(|p| p.full).unwrap_or(false);
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
let is_rescan = payload.as_ref().and_then(|p| p.rescan).unwrap_or(false);
let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" };
// Create indexing job for this library
let job_id = Uuid::new_v4();
@@ -238,6 +269,8 @@ pub struct UpdateMonitoringRequest {
#[schema(value_type = String, example = "hourly")]
pub scan_mode: String, // 'manual', 'hourly', 'daily', 'weekly'
pub watcher_enabled: Option<bool>,
#[schema(value_type = Option<String>, example = "daily")]
pub metadata_refresh_mode: Option<String>, // 'manual', 'hourly', 'daily', 'weekly'
}
/// Update monitoring settings for a library
@@ -268,6 +301,12 @@ pub async fn update_monitoring(
return Err(ApiError::bad_request("scan_mode must be one of: manual, hourly, daily, weekly"));
}
// Validate metadata_refresh_mode
let metadata_refresh_mode = input.metadata_refresh_mode.as_deref().unwrap_or("manual");
if !valid_modes.contains(&metadata_refresh_mode) {
return Err(ApiError::bad_request("metadata_refresh_mode must be one of: manual, hourly, daily, weekly"));
}
// Calculate next_scan_at if monitoring is enabled
let next_scan_at = if input.monitor_enabled {
let interval_minutes = match input.scan_mode.as_str() {
@@ -281,16 +320,31 @@ pub async fn update_monitoring(
None
};
// Calculate next_metadata_refresh_at
let next_metadata_refresh_at = if metadata_refresh_mode != "manual" {
let interval_minutes = match metadata_refresh_mode {
"hourly" => 60,
"daily" => 1440,
"weekly" => 10080,
_ => 1440,
};
Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes))
} else {
None
};
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
let result = sqlx::query(
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider"
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5, metadata_refresh_mode = $6, next_metadata_refresh_at = $7 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
)
.bind(library_id)
.bind(input.monitor_enabled)
.bind(input.scan_mode)
.bind(next_scan_at)
.bind(watcher_enabled)
.bind(metadata_refresh_mode)
.bind(next_metadata_refresh_at)
.fetch_optional(&state.pool)
.await?;
@@ -303,23 +357,45 @@ pub async fn update_monitoring(
.fetch_one(&state.pool)
.await?;
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
.bind(library_id)
.fetch_one(&state.pool)
.await?;
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT b.id FROM books b
WHERE b.library_id = $1
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
LIMIT 5"
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
Ok(Json(LibraryResponse {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count,
series_count,
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
watcher_enabled: row.get("watcher_enabled"),
metadata_provider: row.get("metadata_provider"),
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids,
}))
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateMetadataProviderRequest {
pub metadata_provider: Option<String>,
pub fallback_metadata_provider: Option<String>,
}
/// Update the metadata provider for a library
@@ -345,12 +421,14 @@ pub async fn update_metadata_provider(
Json(input): Json<UpdateMetadataProviderRequest>,
) -> Result<Json<LibraryResponse>, ApiError> {
let provider = input.metadata_provider.as_deref().filter(|s| !s.is_empty());
let fallback = input.fallback_metadata_provider.as_deref().filter(|s| !s.is_empty());
let result = sqlx::query(
"UPDATE libraries SET metadata_provider = $2 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider"
"UPDATE libraries SET metadata_provider = $2, fallback_metadata_provider = $3 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
)
.bind(library_id)
.bind(provider)
.bind(fallback)
.fetch_optional(&state.pool)
.await?;
@@ -363,16 +441,37 @@ pub async fn update_metadata_provider(
.fetch_one(&state.pool)
.await?;
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
.bind(library_id)
.fetch_one(&state.pool)
.await?;
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT b.id FROM books b
WHERE b.library_id = $1
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
LIMIT 5"
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
Ok(Json(LibraryResponse {
id: row.get("id"),
name: row.get("name"),
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count,
series_count,
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
watcher_enabled: row.get("watcher_enabled"),
metadata_provider: row.get("metadata_provider"),
fallback_metadata_provider: row.get("fallback_metadata_provider"),
metadata_refresh_mode: row.get("metadata_refresh_mode"),
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
thumbnail_book_ids,
}))
}

View File

@@ -1,22 +1,31 @@
mod auth;
mod authors;
mod books;
mod error;
mod handlers;
mod index_jobs;
mod job_poller;
mod komga;
mod libraries;
mod metadata;
mod metadata_batch;
mod metadata_refresh;
mod metadata_providers;
mod api_middleware;
mod openapi;
mod pages;
mod prowlarr;
mod qbittorrent;
mod reading_progress;
mod search;
mod series;
mod settings;
mod state;
mod stats;
mod telegram;
mod thumbnails;
mod tokens;
mod users;
use std::sync::Arc;
use std::time::Instant;
@@ -81,14 +90,13 @@ async fn main() -> anyhow::Result<()> {
};
let admin_routes = Router::new()
.route("/libraries", get(libraries::list_libraries).post(libraries::create_library))
.route("/libraries", axum::routing::post(libraries::create_library))
.route("/libraries/:id", delete(libraries::delete_library))
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
.route("/books/:id", axum::routing::patch(books::update_book))
.route("/books/:id/convert", axum::routing::post(books::convert_book))
.route("/libraries/:library_id/series/:name", axum::routing::patch(books::update_series))
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
@@ -99,9 +107,16 @@ async fn main() -> anyhow::Result<()> {
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
.route("/folders", get(index_jobs::list_folders))
.route("/admin/users", get(users::list_users).post(users::create_user))
.route("/admin/users/:id", delete(users::delete_user).patch(users::update_user))
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
.route("/admin/tokens/:id", delete(tokens::revoke_token))
.route("/admin/tokens/:id", delete(tokens::revoke_token).patch(tokens::update_token))
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
.route("/telegram/test", get(telegram::test_telegram))
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
.route("/komga/reports", get(komga::list_sync_reports))
.route("/komga/reports/:id", get(komga::get_sync_report))
@@ -112,6 +127,11 @@ async fn main() -> anyhow::Result<()> {
.route("/metadata/links", get(metadata::get_metadata_links))
.route("/metadata/missing/:id", get(metadata::get_missing_books))
.route("/metadata/links/:id", delete(metadata::delete_metadata_link))
.route("/metadata/batch", axum::routing::post(metadata_batch::start_batch))
.route("/metadata/batch/:id/report", get(metadata_batch::get_batch_report))
.route("/metadata/batch/:id/results", get(metadata_batch::get_batch_results))
.route("/metadata/refresh", axum::routing::post(metadata_refresh::start_refresh))
.route("/metadata/refresh/:id/report", get(metadata_refresh::get_refresh_report))
.merge(settings::settings_routes())
.route_layer(middleware::from_fn_with_state(
state.clone(),
@@ -119,17 +139,22 @@ async fn main() -> anyhow::Result<()> {
));
let read_routes = Router::new()
.route("/libraries", get(libraries::list_libraries))
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
.route("/books", get(books::list_books))
.route("/books/ongoing", get(books::ongoing_books))
.route("/books/ongoing", get(series::ongoing_books))
.route("/books/:id", get(books::get_book))
.route("/books/:id/thumbnail", get(books::get_thumbnail))
.route("/books/:id/pages/:n", get(pages::get_page))
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
.route("/libraries/:library_id/series", get(books::list_series))
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
.route("/series", get(books::list_all_series))
.route("/series/ongoing", get(books::ongoing_series))
.route("/libraries/:library_id/series", get(series::list_series))
.route("/libraries/:library_id/series/:name/metadata", get(series::get_series_metadata))
.route("/series", get(series::list_all_series))
.route("/series/ongoing", get(series::ongoing_series))
.route("/series/statuses", get(series::series_statuses))
.route("/series/provider-statuses", get(series::provider_statuses))
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
.route("/authors", get(authors::list_authors))
.route("/stats", get(stats::get_stats))
.route("/search", get(search::search_books))
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
@@ -138,6 +163,9 @@ async fn main() -> anyhow::Result<()> {
auth::require_read,
));
// Clone pool before state is moved into the router
let poller_pool = state.pool.clone();
let app = Router::new()
.route("/health", get(handlers::health))
.route("/ready", get(handlers::ready))
@@ -149,6 +177,11 @@ async fn main() -> anyhow::Result<()> {
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
.with_state(state);
// Start background poller for API-only jobs (metadata_batch, metadata_refresh)
tokio::spawn(async move {
job_poller::run_job_poller(poller_pool, 5).await;
});
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "api listening");
axum::serve(listener, app).await?;

View File

@@ -369,6 +369,26 @@ pub async fn approve_metadata(
.await?;
}
// Notify via Telegram (with first book thumbnail if available)
let provider_for_notif: String = row.get("provider");
let thumbnail_path: Option<String> = sqlx::query_scalar(
"SELECT thumbnail_path FROM books WHERE library_id = $1 AND series_name = $2 AND thumbnail_path IS NOT NULL ORDER BY sort_order LIMIT 1",
)
.bind(library_id)
.bind(&series_name)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
notifications::notify(
state.pool.clone(),
notifications::NotificationEvent::MetadataApproved {
series_name: series_name.clone(),
provider: provider_for_notif,
thumbnail_path,
},
);
Ok(Json(ApproveResponse {
status: "approved".to_string(),
report,
@@ -590,7 +610,7 @@ fn row_to_link_dto(row: &sqlx::postgres::PgRow) -> ExternalMetadataLinkDto {
}
}
async fn get_provider_for_library(state: &AppState, library_id: Uuid) -> Result<String, ApiError> {
pub(crate) async fn get_provider_for_library(state: &AppState, library_id: Uuid) -> Result<String, ApiError> {
// Check library-level provider first
let row = sqlx::query("SELECT metadata_provider FROM libraries WHERE id = $1")
.bind(library_id)
@@ -623,7 +643,7 @@ async fn get_provider_for_library(state: &AppState, library_id: Uuid) -> Result<
Ok("google_books".to_string())
}
async fn load_provider_config(
pub(crate) async fn load_provider_config(
state: &AppState,
provider_name: &str,
) -> metadata_providers::ProviderConfig {
@@ -661,7 +681,7 @@ async fn load_provider_config(
config
}
async fn sync_series_metadata(
pub(crate) async fn sync_series_metadata(
state: &AppState,
library_id: Uuid,
series_name: &str,
@@ -693,10 +713,11 @@ async fn sync_series_metadata(
.get("start_year")
.and_then(|y| y.as_i64())
.map(|y| y as i32);
let status = metadata_json
.get("status")
.and_then(|s| s.as_str())
.map(normalize_series_status);
let status = if let Some(raw) = metadata_json.get("status").and_then(|s| s.as_str()) {
Some(normalize_series_status(&state.pool, raw).await)
} else {
None
};
// Fetch existing state before upsert
let existing = sqlx::query(
@@ -775,7 +796,7 @@ async fn sync_series_metadata(
let fields = vec![
FieldDef {
name: "description",
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("description")).map(|s| serde_json::Value::String(s)),
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("description")).map(serde_json::Value::String),
new: description.map(|s| serde_json::Value::String(s.to_string())),
},
FieldDef {
@@ -800,8 +821,8 @@ async fn sync_series_metadata(
},
FieldDef {
name: "status",
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("status")).map(|s| serde_json::Value::String(s)),
new: status.as_ref().map(|s| serde_json::Value::String(s.clone())),
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("status")).map(serde_json::Value::String),
new: status.as_ref().map(|s: &String| serde_json::Value::String(s.clone())),
},
];
@@ -825,28 +846,38 @@ async fn sync_series_metadata(
Ok(report)
}
/// Normalize provider-specific status strings to a standard set:
/// "ongoing", "ended", "hiatus", "cancelled", or the original lowercase value
fn normalize_series_status(raw: &str) -> String {
/// Normalize provider-specific status strings using the status_mappings table.
/// Returns None if no mapping is found — unknown statuses are not stored.
pub(crate) async fn normalize_series_status(pool: &sqlx::PgPool, raw: &str) -> String {
let lower = raw.to_lowercase();
match lower.as_str() {
// AniList
"finished" => "ended".to_string(),
"releasing" => "ongoing".to_string(),
"not_yet_released" => "upcoming".to_string(),
"cancelled" => "cancelled".to_string(),
"hiatus" => "hiatus".to_string(),
// Bédéthèque
_ if lower.contains("finie") || lower.contains("terminée") => "ended".to_string(),
_ if lower.contains("en cours") => "ongoing".to_string(),
_ if lower.contains("hiatus") || lower.contains("suspendue") => "hiatus".to_string(),
_ if lower.contains("annulée") || lower.contains("arrêtée") => "cancelled".to_string(),
// Fallback
_ => lower,
// Try exact match first (only mapped entries)
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
"SELECT mapped_status FROM status_mappings WHERE provider_status = $1 AND mapped_status IS NOT NULL",
)
.bind(&lower)
.fetch_optional(pool)
.await
{
return row;
}
// Try substring match (for Bédéthèque-style statuses like "Série finie")
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
"SELECT mapped_status FROM status_mappings WHERE $1 LIKE '%' || provider_status || '%' AND mapped_status IS NOT NULL LIMIT 1",
)
.bind(&lower)
.fetch_optional(pool)
.await
{
return row;
}
// No mapping found — return the provider status as-is (lowercased)
lower
}
async fn sync_books_metadata(
pub(crate) async fn sync_books_metadata(
state: &AppState,
link_id: Uuid,
library_id: Uuid,

File diff suppressed because it is too large Load Diff

View File

@@ -128,7 +128,7 @@ async fn search_series_impl(
let mut candidates: Vec<SeriesCandidate> = media
.iter()
.filter_map(|m| {
let id = m.get("id").and_then(|id| id.as_i64())? as i64;
let id = m.get("id").and_then(|id| id.as_i64())?;
let title_obj = m.get("title")?;
let title = title_obj
.get("english")

View File

@@ -497,6 +497,13 @@ async fn get_series_books_impl(
}))
.collect();
static RE_TOME: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)-Tome-\d+-").unwrap());
static RE_BOOK_ID: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"-(\d+)\.html").unwrap());
static RE_VOLUME: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)Tome-(\d+)-").unwrap());
for (idx, album_el) in doc.select(&album_sel).enumerate() {
// Title from <a class="titre" title="..."> — the title attribute is clean
let title_sel = Selector::parse("a.titre").ok();
@@ -513,16 +520,21 @@ async fn get_series_books_impl(
// External book ID from album URL (e.g. "...-1063.html")
let album_url = title_el.and_then(|el| el.value().attr("href")).unwrap_or("");
let external_book_id = regex::Regex::new(r"-(\d+)\.html")
.ok()
.and_then(|re| re.captures(album_url))
// Only keep main tomes — their URLs contain "Tome-{N}-"
// Skip hors-série (HS), intégrales (INT/INTFL), romans, coffrets, etc.
if !RE_TOME.is_match(album_url) {
continue;
}
let external_book_id = RE_BOOK_ID
.captures(album_url)
.map(|c| c[1].to_string())
.unwrap_or_default();
// Volume number from URL pattern "Tome-{N}-" or from itemprop name
let volume_number = regex::Regex::new(r"(?i)Tome-(\d+)-")
.ok()
.and_then(|re| re.captures(album_url))
let volume_number = RE_VOLUME
.captures(album_url)
.and_then(|c| c[1].parse::<i32>().ok())
.or_else(|| extract_volume_from_title(&title));
@@ -610,20 +622,50 @@ fn extract_volume_from_title(title: &str) -> Option<i32> {
None
}
/// Normalize a title by removing French articles (leading or in parentheses)
/// and extra whitespace/punctuation, so that "Les Légendaires - Résistance"
/// and "Légendaires (Les) - Résistance" produce the same canonical form.
fn normalize_title(s: &str) -> String {
let lower = s.to_lowercase();
// Remove articles in parentheses: "(les)", "(la)", "(le)", "(l')", "(un)", "(une)", "(des)"
let re_parens = regex::Regex::new(r"\s*\((?:les?|la|l'|une?|des|du|d')\)").unwrap();
let cleaned = re_parens.replace_all(&lower, "");
// Remove leading articles: "les ", "la ", "le ", "l'", "un ", "une ", "des ", "du ", "d'"
let re_leading = regex::Regex::new(r"^(?:les?|la|l'|une?|des|du|d')\s+").unwrap();
let cleaned = re_leading.replace(&cleaned, "");
// Collapse multiple spaces/dashes into single
let re_spaces = regex::Regex::new(r"\s+").unwrap();
re_spaces.replace_all(cleaned.trim(), " ").to_string()
}
fn compute_confidence(title: &str, query: &str) -> f32 {
let title_lower = title.to_lowercase();
if title_lower == query {
1.0
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
let query_lower = query.to_lowercase();
if title_lower == query_lower {
return 1.0;
}
// Try with normalized forms (handles Bedetheque's "Name (Article)" convention)
let title_norm = normalize_title(title);
let query_norm = normalize_title(query);
if title_norm == query_norm {
return 1.0;
}
if title_lower.starts_with(&query_lower) || query_lower.starts_with(&title_lower)
|| title_norm.starts_with(&query_norm) || query_norm.starts_with(&title_norm)
{
0.85
} else if title_lower.contains(query) || query.contains(&title_lower) {
} else if title_lower.contains(&query_lower) || query_lower.contains(&title_lower)
|| title_norm.contains(&query_norm) || query_norm.contains(&title_norm)
{
0.7
} else {
let common: usize = query
let common: usize = query_lower
.chars()
.filter(|c| title_lower.contains(*c))
.count();
let max_len = query.len().max(title_lower.len()).max(1);
let max_len = query_lower.len().max(title_lower.len()).max(1);
(common as f32 / max_len as f32).clamp(0.1, 0.6)
}
}

View File

@@ -86,11 +86,11 @@ async fn search_series_impl(
.iter()
.filter_map(|vol| {
let name = vol.get("name").and_then(|n| n.as_str())?.to_string();
let id = vol.get("id").and_then(|id| id.as_i64())? as i64;
let id = vol.get("id").and_then(|id| id.as_i64())?;
let description = vol
.get("description")
.and_then(|d| d.as_str())
.map(|d| strip_html(d));
.map(strip_html);
let publisher = vol
.get("publisher")
.and_then(|p| p.get("name"))
@@ -180,7 +180,7 @@ async fn get_series_books_impl(
let books: Vec<BookCandidate> = results
.iter()
.filter_map(|issue| {
let id = issue.get("id").and_then(|id| id.as_i64())? as i64;
let id = issue.get("id").and_then(|id| id.as_i64())?;
let name = issue
.get("name")
.and_then(|n| n.as_str())
@@ -194,7 +194,7 @@ async fn get_series_books_impl(
let description = issue
.get("description")
.and_then(|d| d.as_str())
.map(|d| strip_html(d));
.map(strip_html);
let cover_url = issue
.get("image")
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))

View File

@@ -295,7 +295,7 @@ async fn get_series_books_impl(
let mut books: Vec<BookCandidate> = items
.iter()
.map(|item| volume_to_book_candidate(item))
.map(volume_to_book_candidate)
.collect();
// Sort by volume number

View File

@@ -144,11 +144,11 @@ async fn search_series_impl(
entry.publishers.push(p.clone());
}
}
if entry.start_year.is_none() || first_publish_year.map_or(false, |y| entry.start_year.unwrap() > y) {
if first_publish_year.is_some() {
if (entry.start_year.is_none() || first_publish_year.is_some_and(|y| entry.start_year.unwrap() > y))
&& first_publish_year.is_some()
{
entry.start_year = first_publish_year;
}
}
if entry.cover_url.is_none() {
entry.cover_url = cover_url;
}

View File

@@ -0,0 +1,836 @@
use axum::{
extract::{Path as AxumPath, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use utoipa::ToSchema;
use tracing::{info, warn};
use crate::{error::ApiError, metadata_providers, state::AppState};
use crate::metadata_batch::{load_provider_config_from_pool, is_job_cancelled, update_progress};
// ---------------------------------------------------------------------------
// DTOs
// ---------------------------------------------------------------------------
#[derive(Deserialize, ToSchema)]
pub struct MetadataRefreshRequest {
pub library_id: String,
}
/// A single field change: old → new
#[derive(Serialize, Clone)]
struct FieldDiff {
field: String,
#[serde(skip_serializing_if = "Option::is_none")]
old: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
new: Option<serde_json::Value>,
}
/// Per-book changes
#[derive(Serialize, Clone)]
struct BookDiff {
book_id: String,
title: String,
volume: Option<i32>,
changes: Vec<FieldDiff>,
}
/// Per-series change report
#[derive(Serialize, Clone)]
struct SeriesRefreshResult {
series_name: String,
provider: String,
status: String, // "updated", "unchanged", "error"
series_changes: Vec<FieldDiff>,
book_changes: Vec<BookDiff>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
/// Response DTO for the report endpoint
#[derive(Serialize, ToSchema)]
pub struct MetadataRefreshReportDto {
#[schema(value_type = String)]
pub job_id: Uuid,
pub status: String,
pub total_links: i64,
pub refreshed: i64,
pub unchanged: i64,
pub errors: i64,
pub changes: serde_json::Value,
}
// ---------------------------------------------------------------------------
// POST /metadata/refresh — Trigger a metadata refresh job
// ---------------------------------------------------------------------------
#[utoipa::path(
post,
path = "/metadata/refresh",
tag = "metadata",
request_body = MetadataRefreshRequest,
responses(
(status = 200, description = "Job created"),
(status = 400, description = "Bad request"),
),
security(("Bearer" = []))
)]
pub async fn start_refresh(
State(state): State<AppState>,
Json(body): Json<MetadataRefreshRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let library_id: Uuid = body
.library_id
.parse()
.map_err(|_| ApiError::bad_request("invalid library_id"))?;
// Verify library exists
sqlx::query("SELECT 1 FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::not_found("library not found"))?;
// Check no existing running metadata_refresh job for this library
let existing: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM index_jobs WHERE library_id = $1 AND type = 'metadata_refresh' AND status IN ('pending', 'running') LIMIT 1",
)
.bind(library_id)
.fetch_optional(&state.pool)
.await?;
if let Some(existing_id) = existing {
return Ok(Json(serde_json::json!({
"id": existing_id.to_string(),
"status": "already_running",
})));
}
// Check there are approved links to refresh (only ongoing series)
let link_count: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*) FROM external_metadata_links eml
LEFT JOIN series_metadata sm
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
WHERE eml.library_id = $1
AND eml.status = 'approved'
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
"#,
)
.bind(library_id)
.fetch_one(&state.pool)
.await?;
if link_count == 0 {
return Err(ApiError::bad_request("No approved metadata links to refresh for this library"));
}
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_refresh', 'running', NOW())",
)
.bind(job_id)
.bind(library_id)
.execute(&state.pool)
.await?;
// Spawn the background processing task (status already 'running' to avoid poller race)
let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
tokio::spawn(async move {
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
let _ = sqlx::query(
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
)
.bind(job_id)
.bind(e.to_string())
.execute(&pool)
.await;
notifications::notify(
pool.clone(),
notifications::NotificationEvent::MetadataRefreshFailed {
library_name,
error: e.to_string(),
},
);
}
});
Ok(Json(serde_json::json!({
"id": job_id.to_string(),
"status": "pending",
})))
}
// ---------------------------------------------------------------------------
// GET /metadata/refresh/:id/report — Refresh report from stats_json
// ---------------------------------------------------------------------------
#[utoipa::path(
get,
path = "/metadata/refresh/{id}/report",
tag = "metadata",
params(("id" = String, Path, description = "Job UUID")),
responses(
(status = 200, body = MetadataRefreshReportDto),
(status = 404, description = "Job not found"),
),
security(("Bearer" = []))
)]
pub async fn get_refresh_report(
State(state): State<AppState>,
AxumPath(job_id): AxumPath<Uuid>,
) -> Result<Json<MetadataRefreshReportDto>, ApiError> {
let row = sqlx::query(
"SELECT status, stats_json, total_files FROM index_jobs WHERE id = $1 AND type = 'metadata_refresh'",
)
.bind(job_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::not_found("job not found"))?;
let job_status: String = row.get("status");
let stats: Option<serde_json::Value> = row.get("stats_json");
let total_files: Option<i32> = row.get("total_files");
let (refreshed, unchanged, errors, changes) = if let Some(ref s) = stats {
(
s.get("refreshed").and_then(|v| v.as_i64()).unwrap_or(0),
s.get("unchanged").and_then(|v| v.as_i64()).unwrap_or(0),
s.get("errors").and_then(|v| v.as_i64()).unwrap_or(0),
s.get("changes").cloned().unwrap_or(serde_json::json!([])),
)
} else {
(0, 0, 0, serde_json::json!([]))
};
Ok(Json(MetadataRefreshReportDto {
job_id,
status: job_status,
total_links: total_files.unwrap_or(0) as i64,
refreshed,
unchanged,
errors,
changes,
}))
}
// ---------------------------------------------------------------------------
// Background processing
// ---------------------------------------------------------------------------
pub(crate) async fn process_metadata_refresh(
pool: &PgPool,
job_id: Uuid,
library_id: Uuid,
) -> Result<(), String> {
// Set job to running
sqlx::query("UPDATE index_jobs SET status = 'running', started_at = NOW() WHERE id = $1")
.bind(job_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
// Get approved links for this library, only for ongoing series (not ended/cancelled)
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
r#"
SELECT eml.id, eml.series_name, eml.provider, eml.external_id
FROM external_metadata_links eml
LEFT JOIN series_metadata sm
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
WHERE eml.library_id = $1
AND eml.status = 'approved'
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
ORDER BY eml.series_name
"#,
)
.bind(library_id)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let total = links.len() as i32;
sqlx::query("UPDATE index_jobs SET total_files = $2 WHERE id = $1")
.bind(job_id)
.bind(total)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
let mut processed = 0i32;
let mut refreshed = 0i32;
let mut unchanged = 0i32;
let mut errors = 0i32;
let mut all_results: Vec<SeriesRefreshResult> = Vec::new();
for (link_id, series_name, provider_name, external_id) in &links {
// Check cancellation
if is_job_cancelled(pool, job_id).await {
sqlx::query(
"UPDATE index_jobs SET status = 'cancelled', finished_at = NOW() WHERE id = $1",
)
.bind(job_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
return Ok(());
}
match refresh_link(pool, *link_id, library_id, series_name, provider_name, external_id).await {
Ok(result) => {
if result.status == "updated" {
refreshed += 1;
info!("[METADATA_REFRESH] job={job_id} updated series='{series_name}' via {provider_name}");
} else {
unchanged += 1;
}
all_results.push(result);
}
Err(e) => {
errors += 1;
warn!("[METADATA_REFRESH] job={job_id} error on series='{series_name}': {e}");
all_results.push(SeriesRefreshResult {
series_name: series_name.clone(),
provider: provider_name.clone(),
status: "error".to_string(),
series_changes: vec![],
book_changes: vec![],
error: Some(e),
});
}
}
processed += 1;
update_progress(pool, job_id, processed, total, series_name).await;
// Rate limit: 1s delay between provider calls
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
}
// Only keep series that have changes or errors (filter out "unchanged")
let changes_only: Vec<&SeriesRefreshResult> = all_results
.iter()
.filter(|r| r.status != "unchanged")
.collect();
// Build stats summary
let stats = serde_json::json!({
"total_links": total,
"refreshed": refreshed,
"unchanged": unchanged,
"errors": errors,
"changes": changes_only,
});
sqlx::query(
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, stats_json = $2 WHERE id = $1",
)
.bind(job_id)
.bind(stats)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
info!("[METADATA_REFRESH] job={job_id} completed: {refreshed} updated, {unchanged} unchanged, {errors} errors");
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
notifications::notify(
pool.clone(),
notifications::NotificationEvent::MetadataRefreshCompleted {
library_name,
refreshed,
unchanged,
errors,
},
);
Ok(())
}
/// Refresh a single approved metadata link: re-fetch from provider, compare, sync, return diff
async fn refresh_link(
pool: &PgPool,
link_id: Uuid,
library_id: Uuid,
series_name: &str,
provider_name: &str,
external_id: &str,
) -> Result<SeriesRefreshResult, String> {
let provider = metadata_providers::get_provider(provider_name)
.ok_or_else(|| format!("Unknown provider: {provider_name}"))?;
let config = load_provider_config_from_pool(pool, provider_name).await;
let mut series_changes: Vec<FieldDiff> = Vec::new();
let mut book_changes: Vec<BookDiff> = Vec::new();
// ── Series-level refresh ──────────────────────────────────────────────
let candidates = provider
.search_series(series_name, &config)
.await
.map_err(|e| format!("provider search error: {e}"))?;
let candidate = candidates
.iter()
.find(|c| c.external_id == external_id)
.or_else(|| candidates.first());
if let Some(candidate) = candidate {
// Update link metadata_json
sqlx::query(
r#"
UPDATE external_metadata_links
SET metadata_json = $2,
total_volumes_external = $3,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(link_id)
.bind(&candidate.metadata_json)
.bind(candidate.total_volumes)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
// Diff + sync series metadata
series_changes = sync_series_with_diff(pool, library_id, series_name, candidate).await?;
}
// ── Book-level refresh ────────────────────────────────────────────────
let books = provider
.get_series_books(external_id, &config)
.await
.map_err(|e| format!("provider books error: {e}"))?;
// Delete existing external_book_metadata for this link
sqlx::query("DELETE FROM external_book_metadata WHERE link_id = $1")
.bind(link_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
// Pre-fetch local books
let local_books: Vec<(Uuid, Option<i32>, String)> = sqlx::query_as(
r#"
SELECT id, volume, title FROM books
WHERE library_id = $1
AND COALESCE(NULLIF(series, ''), 'unclassified') = $2
ORDER BY volume NULLS LAST,
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
title ASC
"#,
)
.bind(library_id)
.bind(series_name)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let local_books_with_pos: Vec<(Uuid, i32, String)> = local_books
.iter()
.enumerate()
.map(|(idx, (id, vol, title))| (*id, vol.unwrap_or((idx + 1) as i32), title.clone()))
.collect();
let mut matched_local_ids = std::collections::HashSet::new();
for (ext_idx, book) in books.iter().enumerate() {
let ext_vol = book.volume_number.unwrap_or((ext_idx + 1) as i32);
// Match by volume number
let mut local_book_id: Option<Uuid> = local_books_with_pos
.iter()
.find(|(id, v, _)| *v == ext_vol && !matched_local_ids.contains(id))
.map(|(id, _, _)| *id);
// Match by title containment
if local_book_id.is_none() {
let ext_title_lower = book.title.to_lowercase();
local_book_id = local_books_with_pos
.iter()
.find(|(id, _, local_title)| {
if matched_local_ids.contains(id) {
return false;
}
let local_lower = local_title.to_lowercase();
local_lower.contains(&ext_title_lower) || ext_title_lower.contains(&local_lower)
})
.map(|(id, _, _)| *id);
}
if let Some(id) = local_book_id {
matched_local_ids.insert(id);
}
// Insert external_book_metadata
sqlx::query(
r#"
INSERT INTO external_book_metadata
(link_id, book_id, external_book_id, volume_number, title, authors, isbn, summary, cover_url, page_count, language, publish_date, metadata_json)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
"#,
)
.bind(link_id)
.bind(local_book_id)
.bind(&book.external_book_id)
.bind(book.volume_number)
.bind(&book.title)
.bind(&book.authors)
.bind(&book.isbn)
.bind(&book.summary)
.bind(&book.cover_url)
.bind(book.page_count)
.bind(&book.language)
.bind(&book.publish_date)
.bind(&book.metadata_json)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
// Diff + push metadata to matched local book
if let Some(book_id) = local_book_id {
let diffs = sync_book_with_diff(pool, book_id, book).await?;
if !diffs.is_empty() {
let local_title = local_books_with_pos
.iter()
.find(|(id, _, _)| *id == book_id)
.map(|(_, _, t)| t.clone())
.unwrap_or_default();
book_changes.push(BookDiff {
book_id: book_id.to_string(),
title: local_title,
volume: book.volume_number,
changes: diffs,
});
}
}
}
// Update synced_at on the link
sqlx::query("UPDATE external_metadata_links SET synced_at = NOW(), updated_at = NOW() WHERE id = $1")
.bind(link_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
let has_changes = !series_changes.is_empty() || !book_changes.is_empty();
Ok(SeriesRefreshResult {
series_name: series_name.to_string(),
provider: provider_name.to_string(),
status: if has_changes { "updated".to_string() } else { "unchanged".to_string() },
series_changes,
book_changes,
error: None,
})
}
// ---------------------------------------------------------------------------
// Diff helpers
// ---------------------------------------------------------------------------
/// Compare old/new for a nullable string field. Returns Some(FieldDiff) only if value actually changed.
fn diff_opt_str(field: &str, old: Option<&str>, new: Option<&str>) -> Option<FieldDiff> {
let new_val = new.filter(|s| !s.is_empty());
// Only report a change if there is a new non-empty value AND it differs from old
match (old, new_val) {
(Some(o), Some(n)) if o != n => Some(FieldDiff {
field: field.to_string(),
old: Some(serde_json::Value::String(o.to_string())),
new: Some(serde_json::Value::String(n.to_string())),
}),
(None, Some(n)) => Some(FieldDiff {
field: field.to_string(),
old: None,
new: Some(serde_json::Value::String(n.to_string())),
}),
_ => None,
}
}
fn diff_opt_i32(field: &str, old: Option<i32>, new: Option<i32>) -> Option<FieldDiff> {
match (old, new) {
(Some(o), Some(n)) if o != n => Some(FieldDiff {
field: field.to_string(),
old: Some(serde_json::json!(o)),
new: Some(serde_json::json!(n)),
}),
(None, Some(n)) => Some(FieldDiff {
field: field.to_string(),
old: None,
new: Some(serde_json::json!(n)),
}),
_ => None,
}
}
fn diff_str_vec(field: &str, old: &[String], new: &[String]) -> Option<FieldDiff> {
if new.is_empty() {
return None;
}
if old != new {
Some(FieldDiff {
field: field.to_string(),
old: Some(serde_json::json!(old)),
new: Some(serde_json::json!(new)),
})
} else {
None
}
}
// ---------------------------------------------------------------------------
// Series sync with diff tracking
// ---------------------------------------------------------------------------
async fn sync_series_with_diff(
pool: &PgPool,
library_id: Uuid,
series_name: &str,
candidate: &metadata_providers::SeriesCandidate,
) -> Result<Vec<FieldDiff>, String> {
let new_description = candidate.metadata_json
.get("description")
.and_then(|d| d.as_str())
.or(candidate.description.as_deref());
let new_authors = &candidate.authors;
let new_publishers = &candidate.publishers;
let new_start_year = candidate.start_year;
let new_total_volumes = candidate.total_volumes;
let new_status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
Some(crate::metadata::normalize_series_status(pool, raw).await)
} else {
None
};
let new_status = new_status.as_deref();
// Fetch existing series metadata for diffing
let existing = sqlx::query(
r#"SELECT description, publishers, start_year, total_volumes, status, authors, locked_fields
FROM series_metadata WHERE library_id = $1 AND name = $2"#,
)
.bind(library_id)
.bind(series_name)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
let locked = existing
.as_ref()
.map(|r| r.get::<serde_json::Value, _>("locked_fields"))
.unwrap_or(serde_json::json!({}));
let is_locked = |field: &str| -> bool {
locked.get(field).and_then(|v| v.as_bool()).unwrap_or(false)
};
// Build diffs (only for unlocked fields that actually change)
let mut diffs: Vec<FieldDiff> = Vec::new();
if !is_locked("description") {
let old_desc: Option<String> = existing.as_ref().and_then(|r| r.get("description"));
if let Some(d) = diff_opt_str("description", old_desc.as_deref(), new_description) {
diffs.push(d);
}
}
if !is_locked("authors") {
let old_authors: Vec<String> = existing.as_ref().map(|r| r.get("authors")).unwrap_or_default();
if let Some(d) = diff_str_vec("authors", &old_authors, new_authors) {
diffs.push(d);
}
}
if !is_locked("publishers") {
let old_publishers: Vec<String> = existing.as_ref().map(|r| r.get("publishers")).unwrap_or_default();
if let Some(d) = diff_str_vec("publishers", &old_publishers, new_publishers) {
diffs.push(d);
}
}
if !is_locked("start_year") {
let old_year: Option<i32> = existing.as_ref().and_then(|r| r.get("start_year"));
if let Some(d) = diff_opt_i32("start_year", old_year, new_start_year) {
diffs.push(d);
}
}
if !is_locked("total_volumes") {
let old_vols: Option<i32> = existing.as_ref().and_then(|r| r.get("total_volumes"));
if let Some(d) = diff_opt_i32("total_volumes", old_vols, new_total_volumes) {
diffs.push(d);
}
}
if !is_locked("status") {
let old_status: Option<String> = existing.as_ref().and_then(|r| r.get("status"));
if let Some(d) = diff_opt_str("status", old_status.as_deref(), new_status) {
diffs.push(d);
}
}
// Now do the actual upsert
sqlx::query(
r#"
INSERT INTO series_metadata (library_id, name, description, publishers, start_year, total_volumes, status, authors, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
ON CONFLICT (library_id, name)
DO UPDATE SET
description = CASE
WHEN (series_metadata.locked_fields->>'description')::boolean IS TRUE THEN series_metadata.description
ELSE COALESCE(NULLIF(EXCLUDED.description, ''), series_metadata.description)
END,
publishers = CASE
WHEN (series_metadata.locked_fields->>'publishers')::boolean IS TRUE THEN series_metadata.publishers
WHEN array_length(EXCLUDED.publishers, 1) > 0 THEN EXCLUDED.publishers
ELSE series_metadata.publishers
END,
start_year = CASE
WHEN (series_metadata.locked_fields->>'start_year')::boolean IS TRUE THEN series_metadata.start_year
ELSE COALESCE(EXCLUDED.start_year, series_metadata.start_year)
END,
total_volumes = CASE
WHEN (series_metadata.locked_fields->>'total_volumes')::boolean IS TRUE THEN series_metadata.total_volumes
ELSE COALESCE(EXCLUDED.total_volumes, series_metadata.total_volumes)
END,
status = CASE
WHEN (series_metadata.locked_fields->>'status')::boolean IS TRUE THEN series_metadata.status
ELSE COALESCE(EXCLUDED.status, series_metadata.status)
END,
authors = CASE
WHEN (series_metadata.locked_fields->>'authors')::boolean IS TRUE THEN series_metadata.authors
WHEN array_length(EXCLUDED.authors, 1) > 0 THEN EXCLUDED.authors
ELSE series_metadata.authors
END,
updated_at = NOW()
"#,
)
.bind(library_id)
.bind(series_name)
.bind(new_description)
.bind(new_publishers)
.bind(new_start_year)
.bind(new_total_volumes)
.bind(new_status)
.bind(new_authors)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Ok(diffs)
}
// ---------------------------------------------------------------------------
// Book sync with diff tracking
// ---------------------------------------------------------------------------
async fn sync_book_with_diff(
pool: &PgPool,
book_id: Uuid,
ext_book: &metadata_providers::BookCandidate,
) -> Result<Vec<FieldDiff>, String> {
// Fetch current book state
let current = sqlx::query(
"SELECT summary, isbn, publish_date, language, authors, locked_fields FROM books WHERE id = $1",
)
.bind(book_id)
.fetch_one(pool)
.await
.map_err(|e| e.to_string())?;
let locked = current.get::<serde_json::Value, _>("locked_fields");
let is_locked = |field: &str| -> bool {
locked.get(field).and_then(|v| v.as_bool()).unwrap_or(false)
};
// Build diffs
let mut diffs: Vec<FieldDiff> = Vec::new();
if !is_locked("summary") {
let old: Option<String> = current.get("summary");
if let Some(d) = diff_opt_str("summary", old.as_deref(), ext_book.summary.as_deref()) {
diffs.push(d);
}
}
if !is_locked("isbn") {
let old: Option<String> = current.get("isbn");
if let Some(d) = diff_opt_str("isbn", old.as_deref(), ext_book.isbn.as_deref()) {
diffs.push(d);
}
}
if !is_locked("publish_date") {
let old: Option<String> = current.get("publish_date");
if let Some(d) = diff_opt_str("publish_date", old.as_deref(), ext_book.publish_date.as_deref()) {
diffs.push(d);
}
}
if !is_locked("language") {
let old: Option<String> = current.get("language");
if let Some(d) = diff_opt_str("language", old.as_deref(), ext_book.language.as_deref()) {
diffs.push(d);
}
}
if !is_locked("authors") {
let old: Vec<String> = current.get("authors");
if let Some(d) = diff_str_vec("authors", &old, &ext_book.authors) {
diffs.push(d);
}
}
// Do the actual update
sqlx::query(
r#"
UPDATE books SET
summary = CASE
WHEN (locked_fields->>'summary')::boolean IS TRUE THEN summary
ELSE COALESCE(NULLIF($2, ''), summary)
END,
isbn = CASE
WHEN (locked_fields->>'isbn')::boolean IS TRUE THEN isbn
ELSE COALESCE(NULLIF($3, ''), isbn)
END,
publish_date = CASE
WHEN (locked_fields->>'publish_date')::boolean IS TRUE THEN publish_date
ELSE COALESCE(NULLIF($4, ''), publish_date)
END,
language = CASE
WHEN (locked_fields->>'language')::boolean IS TRUE THEN language
ELSE COALESCE(NULLIF($5, ''), language)
END,
authors = CASE
WHEN (locked_fields->>'authors')::boolean IS TRUE THEN authors
WHEN CARDINALITY($6::text[]) > 0 THEN $6
ELSE authors
END,
author = CASE
WHEN (locked_fields->>'authors')::boolean IS TRUE THEN author
WHEN CARDINALITY($6::text[]) > 0 THEN $6[1]
ELSE author
END,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(book_id)
.bind(&ext_book.summary)
.bind(&ext_book.isbn)
.bind(&ext_book.publish_date)
.bind(&ext_book.language)
.bind(&ext_book.authors)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Ok(diffs)
}

View File

@@ -10,14 +10,14 @@ use utoipa::OpenApi;
crate::reading_progress::update_reading_progress,
crate::reading_progress::mark_series_read,
crate::books::get_thumbnail,
crate::books::list_series,
crate::books::list_all_series,
crate::books::ongoing_series,
crate::books::ongoing_books,
crate::series::list_series,
crate::series::list_all_series,
crate::series::ongoing_series,
crate::series::ongoing_books,
crate::books::convert_book,
crate::books::update_book,
crate::books::get_series_metadata,
crate::books::update_series,
crate::series::get_series_metadata,
crate::series::update_series,
crate::pages::get_page,
crate::search::search_books,
crate::index_jobs::enqueue_rebuild,
@@ -35,10 +35,12 @@ use utoipa::OpenApi;
crate::libraries::delete_library,
crate::libraries::scan_library,
crate::libraries::update_monitoring,
crate::libraries::update_metadata_provider,
crate::tokens::list_tokens,
crate::tokens::create_token,
crate::tokens::revoke_token,
crate::tokens::delete_token,
crate::authors::list_authors,
crate::stats::get_stats,
crate::settings::get_settings,
crate::settings::get_setting,
@@ -53,6 +55,23 @@ use utoipa::OpenApi;
crate::metadata::get_metadata_links,
crate::metadata::get_missing_books,
crate::metadata::delete_metadata_link,
crate::series::series_statuses,
crate::series::provider_statuses,
crate::settings::list_status_mappings,
crate::settings::upsert_status_mapping,
crate::settings::delete_status_mapping,
crate::prowlarr::search_prowlarr,
crate::prowlarr::test_prowlarr,
crate::qbittorrent::add_torrent,
crate::qbittorrent::test_qbittorrent,
crate::metadata_batch::start_batch,
crate::metadata_batch::get_batch_report,
crate::metadata_batch::get_batch_results,
crate::metadata_refresh::start_refresh,
crate::metadata_refresh::get_refresh_report,
crate::komga::sync_komga_read_books,
crate::komga::list_sync_reports,
crate::komga::get_sync_report,
),
components(
schemas(
@@ -64,14 +83,14 @@ use utoipa::OpenApi;
crate::reading_progress::UpdateReadingProgressRequest,
crate::reading_progress::MarkSeriesReadRequest,
crate::reading_progress::MarkSeriesReadResponse,
crate::books::SeriesItem,
crate::books::SeriesPage,
crate::books::ListAllSeriesQuery,
crate::books::OngoingQuery,
crate::series::SeriesItem,
crate::series::SeriesPage,
crate::series::ListAllSeriesQuery,
crate::series::OngoingQuery,
crate::books::UpdateBookRequest,
crate::books::SeriesMetadata,
crate::books::UpdateSeriesRequest,
crate::books::UpdateSeriesResponse,
crate::series::SeriesMetadata,
crate::series::UpdateSeriesRequest,
crate::series::UpdateSeriesResponse,
crate::pages::PageQuery,
crate::search::SearchQuery,
crate::search::SearchResponse,
@@ -86,6 +105,7 @@ use utoipa::OpenApi;
crate::libraries::LibraryResponse,
crate::libraries::CreateLibraryRequest,
crate::libraries::UpdateMonitoringRequest,
crate::libraries::UpdateMetadataProviderRequest,
crate::tokens::CreateTokenRequest,
crate::tokens::TokenResponse,
crate::tokens::CreatedTokenResponse,
@@ -93,6 +113,11 @@ use utoipa::OpenApi;
crate::settings::ClearCacheResponse,
crate::settings::CacheStats,
crate::settings::ThumbnailStats,
crate::settings::StatusMappingDto,
crate::settings::UpsertStatusMappingRequest,
crate::authors::ListAuthorsQuery,
crate::authors::AuthorItem,
crate::authors::AuthorsPageResponse,
crate::stats::StatsResponse,
crate::stats::StatsOverview,
crate::stats::ReadingStatusStats,
@@ -101,6 +126,8 @@ use utoipa::OpenApi;
crate::stats::LibraryStats,
crate::stats::TopSeries,
crate::stats::MonthlyAdditions,
crate::stats::MetadataStats,
crate::stats::ProviderCount,
crate::metadata::ApproveRequest,
crate::metadata::ApproveResponse,
crate::metadata::SyncReport,
@@ -113,6 +140,23 @@ use utoipa::OpenApi;
crate::metadata::ExternalMetadataLinkDto,
crate::metadata::MissingBooksDto,
crate::metadata::MissingBookItem,
crate::qbittorrent::QBittorrentAddRequest,
crate::qbittorrent::QBittorrentAddResponse,
crate::qbittorrent::QBittorrentTestResponse,
crate::prowlarr::ProwlarrSearchRequest,
crate::prowlarr::ProwlarrRelease,
crate::prowlarr::ProwlarrCategory,
crate::prowlarr::ProwlarrSearchResponse,
crate::prowlarr::MissingVolumeInput,
crate::prowlarr::ProwlarrTestResponse,
crate::metadata_batch::MetadataBatchRequest,
crate::metadata_batch::MetadataBatchReportDto,
crate::metadata_batch::MetadataBatchResultDto,
crate::metadata_refresh::MetadataRefreshRequest,
crate::metadata_refresh::MetadataRefreshReportDto,
crate::komga::KomgaSyncRequest,
crate::komga::KomgaSyncResponse,
crate::komga::KomgaSyncReportSummary,
ErrorResponse,
)
),
@@ -120,12 +164,20 @@ use utoipa::OpenApi;
("Bearer" = [])
),
tags(
(name = "books", description = "Read-only endpoints for browsing and searching books"),
(name = "books", description = "Book browsing, details and management"),
(name = "series", description = "Series browsing, filtering and management"),
(name = "search", description = "Full-text search across books and series"),
(name = "reading-progress", description = "Reading progress tracking per book"),
(name = "libraries", description = "Library management endpoints (Admin only)"),
(name = "authors", description = "Author browsing and listing"),
(name = "stats", description = "Collection statistics and dashboard data"),
(name = "libraries", description = "Library listing, scanning, and management (create/delete/settings: Admin only)"),
(name = "indexing", description = "Search index management and job control (Admin only)"),
(name = "metadata", description = "External metadata providers and matching (Admin only)"),
(name = "komga", description = "Komga read-status sync (Admin only)"),
(name = "tokens", description = "API token management (Admin only)"),
(name = "settings", description = "Application settings and cache management (Admin only)"),
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
(name = "qbittorrent", description = "qBittorrent download client integration (Admin only)"),
),
modifiers(&SecurityAddon)
)]

View File

@@ -277,7 +277,17 @@ pub async fn get_page(
let cache_dir2 = cache_dir_path.clone();
let format2 = format;
tokio::spawn(async move {
prefetch_page(state2, book_id, &abs_path2, next_page, format2, quality, width, filter, timeout_secs, &cache_dir2).await;
prefetch_page(state2, &PrefetchParams {
book_id,
abs_path: &abs_path2,
page: next_page,
format: format2,
quality,
width,
filter,
timeout_secs,
cache_dir: &cache_dir2,
}).await;
});
}
@@ -290,19 +300,30 @@ pub async fn get_page(
}
}
/// Prefetch a single page into disk+memory cache (best-effort, ignores errors).
async fn prefetch_page(
state: AppState,
struct PrefetchParams<'a> {
book_id: Uuid,
abs_path: &str,
abs_path: &'a str,
page: u32,
format: OutputFormat,
quality: u8,
width: u32,
filter: image::imageops::FilterType,
timeout_secs: u64,
cache_dir: &Path,
) {
cache_dir: &'a Path,
}
/// Prefetch a single page into disk+memory cache (best-effort, ignores errors).
async fn prefetch_page(state: AppState, params: &PrefetchParams<'_>) {
let book_id = params.book_id;
let page = params.page;
let format = params.format;
let quality = params.quality;
let width = params.width;
let filter = params.filter;
let timeout_secs = params.timeout_secs;
let abs_path = params.abs_path;
let cache_dir = params.cache_dir;
let mem_key = format!("{book_id}:{page}:{}:{quality}:{width}", format.extension());
// Already in memory cache?
if state.page_cache.lock().await.contains(&mem_key) {
@@ -330,6 +351,7 @@ async fn prefetch_page(
Some(ref e) if e == "cbz" => "cbz",
Some(ref e) if e == "cbr" => "cbr",
Some(ref e) if e == "pdf" => "pdf",
Some(ref e) if e == "epub" => "epub",
_ => return,
}
.to_string();
@@ -458,6 +480,7 @@ fn render_page(
"cbz" => parsers::BookFormat::Cbz,
"cbr" => parsers::BookFormat::Cbr,
"pdf" => parsers::BookFormat::Pdf,
"epub" => parsers::BookFormat::Epub,
_ => return Err(ApiError::bad_request("unsupported source format")),
};

363
apps/api/src/prowlarr.rs Normal file
View File

@@ -0,0 +1,363 @@
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
// ─── Types ──────────────────────────────────────────────────────────────────
#[derive(Deserialize, ToSchema)]
pub struct MissingVolumeInput {
pub volume_number: Option<i32>,
#[allow(dead_code)]
pub title: Option<String>,
}
#[derive(Deserialize, ToSchema)]
pub struct ProwlarrSearchRequest {
pub series_name: String,
pub volume_number: Option<i32>,
pub custom_query: Option<String>,
pub missing_volumes: Option<Vec<MissingVolumeInput>>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProwlarrRawRelease {
pub guid: String,
pub title: String,
pub size: i64,
pub download_url: Option<String>,
pub indexer: Option<String>,
pub seeders: Option<i32>,
pub leechers: Option<i32>,
pub publish_date: Option<String>,
pub protocol: Option<String>,
pub info_url: Option<String>,
pub categories: Option<Vec<ProwlarrCategory>>,
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProwlarrRelease {
pub guid: String,
pub title: String,
pub size: i64,
pub download_url: Option<String>,
pub indexer: Option<String>,
pub seeders: Option<i32>,
pub leechers: Option<i32>,
pub publish_date: Option<String>,
pub protocol: Option<String>,
pub info_url: Option<String>,
pub categories: Option<Vec<ProwlarrCategory>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_missing_volumes: Option<Vec<i32>>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProwlarrCategory {
pub id: i32,
pub name: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ProwlarrSearchResponse {
pub results: Vec<ProwlarrRelease>,
pub query: String,
}
#[derive(Serialize, ToSchema)]
pub struct ProwlarrTestResponse {
pub success: bool,
pub message: String,
pub indexer_count: Option<i32>,
}
// ─── Config helper ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ProwlarrConfig {
url: String,
api_key: String,
categories: Option<Vec<i32>>,
}
async fn load_prowlarr_config(
pool: &sqlx::PgPool,
) -> Result<(String, String, Vec<i32>), ApiError> {
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'prowlarr'")
.fetch_optional(pool)
.await?;
let row = row.ok_or_else(|| ApiError::bad_request("Prowlarr is not configured"))?;
let value: serde_json::Value = row.get("value");
let config: ProwlarrConfig = serde_json::from_value(value)
.map_err(|e| ApiError::internal(format!("invalid prowlarr config: {e}")))?;
if config.url.is_empty() || config.api_key.is_empty() {
return Err(ApiError::bad_request(
"Prowlarr URL and API key must be configured in settings",
));
}
let url = config.url.trim_end_matches('/').to_string();
let categories = config.categories.unwrap_or_else(|| vec![7030, 7020]);
Ok((url, config.api_key, categories))
}
// ─── Volume matching ─────────────────────────────────────────────────────────
/// Extract volume numbers from a release title.
/// Looks for patterns like: T01, Tome 01, Vol. 01, v01, #01,
/// or standalone numbers that appear after common separators.
fn extract_volumes_from_title(title: &str) -> Vec<i32> {
let lower = title.to_lowercase();
let mut volumes = Vec::new();
// Patterns: T01, Tome 01, Tome01, Vol 01, Vol.01, v01, #01
let prefixes = ["tome", "vol.", "vol ", "t", "v", "#"];
let chars: Vec<char> = lower.chars().collect();
let len = chars.len();
for prefix in &prefixes {
let mut start = 0;
while let Some(pos) = lower[start..].find(prefix) {
let abs_pos = start + pos;
let after = abs_pos + prefix.len();
// For single-char prefixes (t, v, #), ensure it's at a word boundary
if prefix.len() == 1 && *prefix != "#" {
if abs_pos > 0 && chars[abs_pos - 1].is_alphanumeric() {
start = after;
continue;
}
}
// Skip optional spaces after prefix
let mut i = after;
while i < len && chars[i] == ' ' {
i += 1;
}
// Read digits
let digit_start = i;
while i < len && chars[i].is_ascii_digit() {
i += 1;
}
if i > digit_start {
if let Ok(num) = lower[digit_start..i].parse::<i32>() {
if !volumes.contains(&num) {
volumes.push(num);
}
}
}
start = after;
}
}
volumes
}
/// Match releases against missing volume numbers.
fn match_missing_volumes(
releases: Vec<ProwlarrRawRelease>,
missing: &[MissingVolumeInput],
) -> Vec<ProwlarrRelease> {
let missing_numbers: Vec<i32> = missing
.iter()
.filter_map(|m| m.volume_number)
.collect();
releases
.into_iter()
.map(|r| {
let matched = if missing_numbers.is_empty() {
None
} else {
let title_volumes = extract_volumes_from_title(&r.title);
let matched: Vec<i32> = title_volumes
.into_iter()
.filter(|v| missing_numbers.contains(v))
.collect();
if matched.is_empty() {
None
} else {
Some(matched)
}
};
ProwlarrRelease {
guid: r.guid,
title: r.title,
size: r.size,
download_url: r.download_url,
indexer: r.indexer,
seeders: r.seeders,
leechers: r.leechers,
publish_date: r.publish_date,
protocol: r.protocol,
info_url: r.info_url,
categories: r.categories,
matched_missing_volumes: matched,
}
})
.collect()
}
// ─── Handlers ───────────────────────────────────────────────────────────────
/// Search for releases on Prowlarr
#[utoipa::path(
post,
path = "/prowlarr/search",
tag = "prowlarr",
request_body = ProwlarrSearchRequest,
responses(
(status = 200, body = ProwlarrSearchResponse),
(status = 400, description = "Bad request or Prowlarr not configured"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Prowlarr connection error"),
),
security(("Bearer" = []))
)]
pub async fn search_prowlarr(
State(state): State<AppState>,
Json(body): Json<ProwlarrSearchRequest>,
) -> Result<Json<ProwlarrSearchResponse>, ApiError> {
let (url, api_key, categories) = load_prowlarr_config(&state.pool).await?;
let query = if let Some(custom) = &body.custom_query {
custom.clone()
} else if let Some(vol) = body.volume_number {
format!("\"{}\" {}", body.series_name, vol)
} else {
format!("\"{}\"", body.series_name)
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
let mut params: Vec<(&str, String)> = vec![
("query", query.clone()),
("type", "search".to_string()),
];
for cat in &categories {
params.push(("categories", cat.to_string()));
}
let resp = client
.get(format!("{url}/api/v1/search"))
.query(&params)
.header("X-Api-Key", &api_key)
.send()
.await
.map_err(|e| ApiError::internal(format!("Prowlarr request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ApiError::internal(format!(
"Prowlarr returned {status}: {text}"
)));
}
let raw_text = resp
.text()
.await
.map_err(|e| ApiError::internal(format!("Failed to read Prowlarr response: {e}")))?;
tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len());
let raw_releases: Vec<ProwlarrRawRelease> = serde_json::from_str(&raw_text)
.map_err(|e| {
tracing::error!("Failed to parse Prowlarr response: {e}");
tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]);
ApiError::internal(format!("Failed to parse Prowlarr response: {e}"))
})?;
let results = if let Some(missing) = &body.missing_volumes {
match_missing_volumes(raw_releases, missing)
} else {
raw_releases
.into_iter()
.map(|r| ProwlarrRelease {
guid: r.guid,
title: r.title,
size: r.size,
download_url: r.download_url,
indexer: r.indexer,
seeders: r.seeders,
leechers: r.leechers,
publish_date: r.publish_date,
protocol: r.protocol,
info_url: r.info_url,
categories: r.categories,
matched_missing_volumes: None,
})
.collect()
};
Ok(Json(ProwlarrSearchResponse { results, query }))
}
/// Test connection to Prowlarr
#[utoipa::path(
get,
path = "/prowlarr/test",
tag = "prowlarr",
responses(
(status = 200, body = ProwlarrTestResponse),
(status = 400, description = "Prowlarr not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn test_prowlarr(
State(state): State<AppState>,
) -> Result<Json<ProwlarrTestResponse>, ApiError> {
let (url, api_key, _categories) = load_prowlarr_config(&state.pool).await?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
let resp = client
.get(format!("{url}/api/v1/indexer"))
.header("X-Api-Key", &api_key)
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
let indexers: Vec<serde_json::Value> = r.json().await.unwrap_or_default();
Ok(Json(ProwlarrTestResponse {
success: true,
message: format!("Connected successfully ({} indexers)", indexers.len()),
indexer_count: Some(indexers.len() as i32),
}))
}
Ok(r) => {
let status = r.status();
let text = r.text().await.unwrap_or_default();
Ok(Json(ProwlarrTestResponse {
success: false,
message: format!("Prowlarr returned {status}: {text}"),
indexer_count: None,
}))
}
Err(e) => Ok(Json(ProwlarrTestResponse {
success: false,
message: format!("Connection failed: {e}"),
indexer_count: None,
})),
}
}

218
apps/api/src/qbittorrent.rs Normal file
View File

@@ -0,0 +1,218 @@
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
// ─── Types ──────────────────────────────────────────────────────────────────
#[derive(Deserialize, ToSchema)]
pub struct QBittorrentAddRequest {
pub url: String,
}
#[derive(Serialize, ToSchema)]
pub struct QBittorrentAddResponse {
pub success: bool,
pub message: String,
}
#[derive(Serialize, ToSchema)]
pub struct QBittorrentTestResponse {
pub success: bool,
pub message: String,
pub version: Option<String>,
}
// ─── Config helper ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct QBittorrentConfig {
url: String,
username: String,
password: String,
}
async fn load_qbittorrent_config(
pool: &sqlx::PgPool,
) -> Result<(String, String, String), ApiError> {
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'qbittorrent'")
.fetch_optional(pool)
.await?;
let row = row.ok_or_else(|| ApiError::bad_request("qBittorrent is not configured"))?;
let value: serde_json::Value = row.get("value");
let config: QBittorrentConfig = serde_json::from_value(value)
.map_err(|e| ApiError::internal(format!("invalid qbittorrent config: {e}")))?;
if config.url.is_empty() || config.username.is_empty() {
return Err(ApiError::bad_request(
"qBittorrent URL and username must be configured in settings",
));
}
let url = config.url.trim_end_matches('/').to_string();
Ok((url, config.username, config.password))
}
// ─── Login helper ───────────────────────────────────────────────────────────
async fn qbittorrent_login(
client: &reqwest::Client,
base_url: &str,
username: &str,
password: &str,
) -> Result<String, ApiError> {
let resp = client
.post(format!("{base_url}/api/v2/auth/login"))
.form(&[("username", username), ("password", password)])
.send()
.await
.map_err(|e| ApiError::internal(format!("qBittorrent login request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ApiError::internal(format!(
"qBittorrent login failed ({status}): {text}"
)));
}
// Extract SID from Set-Cookie header
let cookie_header = resp
.headers()
.get("set-cookie")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let sid = cookie_header
.split(';')
.next()
.and_then(|s| s.strip_prefix("SID="))
.ok_or_else(|| ApiError::internal("Failed to get SID cookie from qBittorrent"))?
.to_string();
Ok(sid)
}
// ─── Handlers ───────────────────────────────────────────────────────────────
/// Add a torrent to qBittorrent
#[utoipa::path(
post,
path = "/qbittorrent/add",
tag = "qbittorrent",
request_body = QBittorrentAddRequest,
responses(
(status = 200, body = QBittorrentAddResponse),
(status = 400, description = "Bad request or qBittorrent not configured"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "qBittorrent connection error"),
),
security(("Bearer" = []))
)]
pub async fn add_torrent(
State(state): State<AppState>,
Json(body): Json<QBittorrentAddRequest>,
) -> Result<Json<QBittorrentAddResponse>, ApiError> {
if body.url.is_empty() {
return Err(ApiError::bad_request("url is required"));
}
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
let sid = qbittorrent_login(&client, &base_url, &username, &password).await?;
let resp = client
.post(format!("{base_url}/api/v2/torrents/add"))
.header("Cookie", format!("SID={sid}"))
.form(&[("urls", &body.url)])
.send()
.await
.map_err(|e| ApiError::internal(format!("qBittorrent add request failed: {e}")))?;
if resp.status().is_success() {
Ok(Json(QBittorrentAddResponse {
success: true,
message: "Torrent added to qBittorrent".to_string(),
}))
} else {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
Ok(Json(QBittorrentAddResponse {
success: false,
message: format!("qBittorrent returned {status}: {text}"),
}))
}
}
/// Test connection to qBittorrent
#[utoipa::path(
get,
path = "/qbittorrent/test",
tag = "qbittorrent",
responses(
(status = 200, body = QBittorrentTestResponse),
(status = 400, description = "qBittorrent not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn test_qbittorrent(
State(state): State<AppState>,
) -> Result<Json<QBittorrentTestResponse>, ApiError> {
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
let sid = match qbittorrent_login(&client, &base_url, &username, &password).await {
Ok(sid) => sid,
Err(e) => {
return Ok(Json(QBittorrentTestResponse {
success: false,
message: format!("Login failed: {}", e.message),
version: None,
}));
}
};
let resp = client
.get(format!("{base_url}/api/v2/app/version"))
.header("Cookie", format!("SID={sid}"))
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
let version = r.text().await.unwrap_or_default();
Ok(Json(QBittorrentTestResponse {
success: true,
message: format!("Connected successfully ({})", version.trim()),
version: Some(version.trim().to_string()),
}))
}
Ok(r) => {
let status = r.status();
let text = r.text().await.unwrap_or_default();
Ok(Json(QBittorrentTestResponse {
success: false,
message: format!("qBittorrent returned {status}: {text}"),
version: None,
}))
}
Err(e) => Ok(Json(QBittorrentTestResponse {
success: false,
message: format!("Connection failed: {e}"),
version: None,
})),
}
}

View File

@@ -1,11 +1,11 @@
use axum::{extract::{Path, State}, Json};
use axum::{extract::{Extension, Path, State}, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
use crate::{auth::AuthUser, error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct ReadingProgressResponse {
@@ -42,8 +42,10 @@ pub struct UpdateReadingProgressRequest {
)]
pub async fn get_reading_progress(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Path(id): Path<Uuid>,
) -> Result<Json<ReadingProgressResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
// Verify book exists
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
.bind(id)
@@ -55,9 +57,10 @@ pub async fn get_reading_progress(
}
let row = sqlx::query(
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1 AND user_id = $2",
)
.bind(id)
.bind(auth_user.user_id)
.fetch_optional(&state.pool)
.await?;
@@ -96,9 +99,11 @@ pub async fn get_reading_progress(
)]
pub async fn update_reading_progress(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateReadingProgressRequest>,
) -> Result<Json<ReadingProgressResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
// Validate status value
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
return Err(ApiError::bad_request(format!(
@@ -143,9 +148,9 @@ pub async fn update_reading_progress(
let row = sqlx::query(
r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (book_id) DO UPDATE
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = EXCLUDED.status,
current_page = EXCLUDED.current_page,
last_read_at = NOW(),
@@ -154,6 +159,7 @@ pub async fn update_reading_progress(
"#,
)
.bind(id)
.bind(auth_user.user_id)
.bind(&body.status)
.bind(current_page)
.fetch_one(&state.pool)
@@ -194,8 +200,10 @@ pub struct MarkSeriesReadResponse {
)]
pub async fn mark_series_read(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Json(body): Json<MarkSeriesReadRequest>,
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
if !["read", "unread"].contains(&body.status.as_str()) {
return Err(ApiError::bad_request(
"status must be 'read' or 'unread'",
@@ -209,24 +217,50 @@ pub async fn mark_series_read(
};
let sql = if body.status == "unread" {
// Delete progress records to reset to unread
// Delete progress records to reset to unread (scoped to this user)
if body.series == "unclassified" {
format!(
r#"
WITH target_books AS (
SELECT id FROM books WHERE {series_filter}
)
DELETE FROM book_reading_progress
WHERE book_id IN (SELECT id FROM target_books)
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $1
"#
)
} else {
format!(
r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
SELECT id, 'read', NULL, NOW(), NOW()
WITH target_books AS (
SELECT id FROM books WHERE {series_filter}
)
DELETE FROM book_reading_progress
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $2
"#
)
}
} else if body.series == "unclassified" {
format!(
r#"
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT id, $1, 'read', NULL, NOW(), NOW()
FROM books
WHERE {series_filter}
ON CONFLICT (book_id) DO UPDATE
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read',
current_page = NULL,
last_read_at = NOW(),
updated_at = NOW()
"#
)
} else {
format!(
r#"
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT id, $2, 'read', NULL, NOW(), NOW()
FROM books
WHERE {series_filter}
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read',
current_page = NULL,
last_read_at = NOW(),
@@ -236,9 +270,18 @@ pub async fn mark_series_read(
};
let result = if body.series == "unclassified" {
sqlx::query(&sql).execute(&state.pool).await?
// $1 = user_id (no series bind needed)
sqlx::query(&sql)
.bind(auth_user.user_id)
.execute(&state.pool)
.await?
} else {
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await?
// $1 = series, $2 = user_id
sqlx::query(&sql)
.bind(&body.series)
.bind(auth_user.user_id)
.execute(&state.pool)
.await?
};
Ok(Json(MarkSeriesReadResponse {

View File

@@ -43,11 +43,11 @@ pub struct SearchResponse {
#[utoipa::path(
get,
path = "/search",
tag = "books",
tag = "search",
params(
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
("library_id" = Option<String>, Query, description = "Filter by library ID"),
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf, epub)"),
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
("limit" = Option<usize>, Query, description = "Max results per type (max 100)"),
),

1043
apps/api/src/series.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
use axum::{
extract::State,
routing::{get, post},
extract::{Path as AxumPath, State},
routing::{delete, get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::{AppState, load_dynamic_settings}};
@@ -42,6 +43,14 @@ pub fn settings_routes() -> Router<AppState> {
.route("/settings/cache/clear", post(clear_cache))
.route("/settings/cache/stats", get(get_cache_stats))
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
.route(
"/settings/status-mappings",
get(list_status_mappings).post(upsert_status_mapping),
)
.route(
"/settings/status-mappings/:id",
delete(delete_status_mapping),
)
}
/// List all settings
@@ -324,3 +333,125 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
Ok(Json(stats))
}
// ---------------------------------------------------------------------------
// Status Mappings
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct StatusMappingDto {
pub id: String,
pub provider_status: String,
pub mapped_status: Option<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpsertStatusMappingRequest {
pub provider_status: String,
pub mapped_status: String,
}
/// List all status mappings
#[utoipa::path(
get,
path = "/settings/status-mappings",
tag = "settings",
responses(
(status = 200, body = Vec<StatusMappingDto>),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn list_status_mappings(
State(state): State<AppState>,
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
let rows = sqlx::query(
"SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status NULLS LAST, provider_status",
)
.fetch_all(&state.pool)
.await?;
let mappings = rows
.iter()
.map(|row| StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get::<Option<String>, _>("mapped_status"),
})
.collect();
Ok(Json(mappings))
}
/// Create or update a status mapping
#[utoipa::path(
post,
path = "/settings/status-mappings",
tag = "settings",
request_body = UpsertStatusMappingRequest,
responses(
(status = 200, body = StatusMappingDto),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn upsert_status_mapping(
State(state): State<AppState>,
Json(body): Json<UpsertStatusMappingRequest>,
) -> Result<Json<StatusMappingDto>, ApiError> {
let provider_status = body.provider_status.to_lowercase();
let row = sqlx::query(
r#"
INSERT INTO status_mappings (provider_status, mapped_status)
VALUES ($1, $2)
ON CONFLICT (provider_status)
DO UPDATE SET mapped_status = $2, updated_at = NOW()
RETURNING id, provider_status, mapped_status
"#,
)
.bind(&provider_status)
.bind(&body.mapped_status)
.fetch_one(&state.pool)
.await?;
Ok(Json(StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get::<Option<String>, _>("mapped_status"),
}))
}
/// Unmap a status mapping (sets mapped_status to NULL, keeps the provider status known)
#[utoipa::path(
delete,
path = "/settings/status-mappings/{id}",
tag = "settings",
params(("id" = String, Path, description = "Mapping UUID")),
responses(
(status = 200, body = StatusMappingDto),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("Bearer" = []))
)]
pub async fn delete_status_mapping(
State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<StatusMappingDto>, ApiError> {
let row = sqlx::query(
"UPDATE status_mappings SET mapped_status = NULL, updated_at = NOW() WHERE id = $1 RETURNING id, provider_status, mapped_status",
)
.bind(id)
.fetch_optional(&state.pool)
.await?;
match row {
Some(row) => Ok(Json(StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get::<Option<String>, _>("mapped_status"),
})),
None => Err(ApiError::not_found("status mapping not found")),
}
}

View File

@@ -1,9 +1,18 @@
use axum::{extract::State, Json};
use serde::Serialize;
use axum::{
extract::{Extension, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use crate::{error::ApiError, state::AppState};
use crate::{auth::AuthUser, error::ApiError, state::AppState};
#[derive(Deserialize, IntoParams)]
pub struct StatsQuery {
/// Granularity: "day", "week" or "month" (default: "month")
pub period: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct StatsOverview {
@@ -58,22 +67,86 @@ pub struct MonthlyAdditions {
pub books_added: i64,
}
#[derive(Serialize, ToSchema)]
pub struct MetadataStats {
pub total_series: i64,
pub series_linked: i64,
pub series_unlinked: i64,
pub books_with_summary: i64,
pub books_with_isbn: i64,
pub by_provider: Vec<ProviderCount>,
}
#[derive(Serialize, ToSchema)]
pub struct ProviderCount {
pub provider: String,
pub count: i64,
}
#[derive(Serialize, ToSchema)]
pub struct CurrentlyReadingItem {
pub book_id: String,
pub title: String,
pub series: Option<String>,
pub current_page: i32,
pub page_count: i32,
pub username: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct RecentlyReadItem {
pub book_id: String,
pub title: String,
pub series: Option<String>,
pub last_read_at: String,
pub username: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct MonthlyReading {
pub month: String,
pub books_read: i64,
}
#[derive(Serialize, ToSchema)]
pub struct UserMonthlyReading {
pub month: String,
pub username: String,
pub books_read: i64,
}
#[derive(Serialize, ToSchema)]
pub struct JobTimePoint {
pub label: String,
pub scan: i64,
pub rebuild: i64,
pub thumbnail: i64,
pub other: i64,
}
#[derive(Serialize, ToSchema)]
pub struct StatsResponse {
pub overview: StatsOverview,
pub reading_status: ReadingStatusStats,
pub currently_reading: Vec<CurrentlyReadingItem>,
pub recently_read: Vec<RecentlyReadItem>,
pub reading_over_time: Vec<MonthlyReading>,
pub by_format: Vec<FormatCount>,
pub by_language: Vec<LanguageCount>,
pub by_library: Vec<LibraryStats>,
pub top_series: Vec<TopSeries>,
pub additions_over_time: Vec<MonthlyAdditions>,
pub jobs_over_time: Vec<JobTimePoint>,
pub metadata: MetadataStats,
pub users_reading_over_time: Vec<UserMonthlyReading>,
}
/// Get collection statistics for the dashboard
#[utoipa::path(
get,
path = "/stats",
tag = "books",
tag = "stats",
params(StatsQuery),
responses(
(status = 200, body = StatsResponse),
(status = 401, description = "Unauthorized"),
@@ -82,7 +155,11 @@ pub struct StatsResponse {
)]
pub async fn get_stats(
State(state): State<AppState>,
Query(query): Query<StatsQuery>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<StatsResponse>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let period = query.period.as_deref().unwrap_or("month");
// Overview + reading status in one query
let overview_row = sqlx::query(
r#"
@@ -100,9 +177,10 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
"#,
)
.bind(user_id)
.fetch_one(&state.pool)
.await?;
@@ -190,7 +268,7 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
FROM libraries l
LEFT JOIN books b ON b.library_id = l.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
LEFT JOIN LATERAL (
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
) bf ON TRUE
@@ -198,6 +276,7 @@ pub async fn get_stats(
ORDER BY book_count DESC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
@@ -222,13 +301,14 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
WHERE b.series IS NOT NULL AND b.series != ''
GROUP BY b.series
ORDER BY book_count DESC
LIMIT 10
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
@@ -242,20 +322,74 @@ pub async fn get_stats(
})
.collect();
// Additions over time (last 12 months)
let additions_rows = sqlx::query(
// Additions over time (with gap filling)
let additions_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
COUNT(*) AS books_added
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT created_at::date AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', created_at)
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY created_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?;
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
.iter()
@@ -265,13 +399,421 @@ pub async fn get_stats(
})
.collect();
// Metadata stats
let meta_row = sqlx::query(
r#"
SELECT
(SELECT COUNT(DISTINCT NULLIF(series, '')) FROM books) AS total_series,
(SELECT COUNT(DISTINCT series_name) FROM external_metadata_links WHERE status = 'approved') AS series_linked,
(SELECT COUNT(*) FROM books WHERE summary IS NOT NULL AND summary != '') AS books_with_summary,
(SELECT COUNT(*) FROM books WHERE isbn IS NOT NULL AND isbn != '') AS books_with_isbn
"#,
)
.fetch_one(&state.pool)
.await?;
let meta_total_series: i64 = meta_row.get("total_series");
let meta_series_linked: i64 = meta_row.get("series_linked");
let provider_rows = sqlx::query(
r#"
SELECT provider, COUNT(DISTINCT series_name) AS count
FROM external_metadata_links
WHERE status = 'approved'
GROUP BY provider
ORDER BY count DESC
"#,
)
.fetch_all(&state.pool)
.await?;
let by_provider: Vec<ProviderCount> = provider_rows
.iter()
.map(|r| ProviderCount {
provider: r.get("provider"),
count: r.get("count"),
})
.collect();
let metadata = MetadataStats {
total_series: meta_total_series,
series_linked: meta_series_linked,
series_unlinked: meta_total_series - meta_series_linked,
books_with_summary: meta_row.get("books_with_summary"),
books_with_isbn: meta_row.get("books_with_isbn"),
by_provider,
};
// Currently reading books
let reading_rows = sqlx::query(
r#"
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count, u.username
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
LEFT JOIN users u ON u.id = brp.user_id
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
AND ($1::uuid IS NULL OR brp.user_id = $1)
ORDER BY brp.updated_at DESC
LIMIT 20
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
let currently_reading: Vec<CurrentlyReadingItem> = reading_rows
.iter()
.map(|r| {
let id: uuid::Uuid = r.get("book_id");
CurrentlyReadingItem {
book_id: id.to_string(),
title: r.get("title"),
series: r.get("series"),
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
username: r.get("username"),
}
})
.collect();
// Recently read books
let recent_rows = sqlx::query(
r#"
SELECT b.id AS book_id, b.title, b.series,
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at,
u.username
FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id
LEFT JOIN users u ON u.id = brp.user_id
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
AND ($1::uuid IS NULL OR brp.user_id = $1)
ORDER BY brp.last_read_at DESC
LIMIT 10
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
let recently_read: Vec<RecentlyReadItem> = recent_rows
.iter()
.map(|r| {
let id: uuid::Uuid = r.get("book_id");
RecentlyReadItem {
book_id: id.to_string(),
title: r.get("title"),
series: r.get("series"),
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
username: r.get("username"),
}
})
.collect();
// Reading activity over time (with gap filling)
let reading_time_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY brp.last_read_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('week', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('month', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
};
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
.iter()
.map(|r| MonthlyReading {
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
books_read: r.get("books_read"),
})
.collect();
// Per-user reading over time (admin view — always all users, no user_id filter)
let users_reading_time_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY brp.last_read_at::date, brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', brp.last_read_at), brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', brp.last_read_at), brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let users_reading_over_time: Vec<UserMonthlyReading> = users_reading_time_rows
.iter()
.map(|r| UserMonthlyReading {
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
username: r.get("username"),
books_read: r.get("books_read"),
})
.collect();
// Jobs over time (with gap filling, grouped by type category)
let jobs_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT
finished_at::date AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY finished_at::date, cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT
DATE_TRUNC('week', finished_at) AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', finished_at), cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT
DATE_TRUNC('month', finished_at) AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', finished_at), cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
.iter()
.map(|r| JobTimePoint {
label: r.get("label"),
scan: r.get("scan"),
rebuild: r.get("rebuild"),
thumbnail: r.get("thumbnail"),
other: r.get("other"),
})
.collect();
Ok(Json(StatsResponse {
overview,
reading_status,
currently_reading,
recently_read,
reading_over_time,
by_format,
by_language,
by_library,
top_series,
additions_over_time,
jobs_over_time,
metadata,
users_reading_over_time,
}))
}

46
apps/api/src/telegram.rs Normal file
View File

@@ -0,0 +1,46 @@
use axum::{extract::State, Json};
use serde::Serialize;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct TelegramTestResponse {
pub success: bool,
pub message: String,
}
/// Test Telegram connection by sending a test message
#[utoipa::path(
get,
path = "/telegram/test",
tag = "notifications",
responses(
(status = 200, body = TelegramTestResponse),
(status = 400, description = "Telegram not configured"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn test_telegram(
State(state): State<AppState>,
) -> Result<Json<TelegramTestResponse>, ApiError> {
let config = notifications::load_telegram_config(&state.pool)
.await
.ok_or_else(|| {
ApiError::bad_request(
"Telegram is not configured or disabled. Set bot_token, chat_id, and enable it.",
)
})?;
match notifications::send_test_message(&config).await {
Ok(()) => Ok(Json(TelegramTestResponse {
success: true,
message: "Test message sent successfully".to_string(),
})),
Err(e) => Ok(Json(TelegramTestResponse {
success: false,
message: format!("Failed to send: {e}"),
})),
}
}

View File

@@ -16,6 +16,8 @@ pub struct CreateTokenRequest {
pub name: String,
#[schema(value_type = Option<String>, example = "read")]
pub scope: Option<String>,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
}
#[derive(Serialize, ToSchema)]
@@ -26,6 +28,9 @@ pub struct TokenResponse {
pub scope: String,
pub prefix: String,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub username: Option<String>,
#[schema(value_type = Option<String>)]
pub last_used_at: Option<DateTime<Utc>>,
#[schema(value_type = Option<String>)]
pub revoked_at: Option<DateTime<Utc>>,
@@ -71,6 +76,10 @@ pub async fn create_token(
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
};
if scope == "read" && input.user_id.is_none() {
return Err(ApiError::bad_request("user_id is required for read-scoped tokens"));
}
let mut random = [0u8; 24];
OsRng.fill_bytes(&mut random);
let secret = URL_SAFE_NO_PAD.encode(random);
@@ -85,13 +94,14 @@ pub async fn create_token(
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope, user_id) VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(id)
.bind(input.name.trim())
.bind(&prefix)
.bind(token_hash)
.bind(scope)
.bind(input.user_id)
.execute(&state.pool)
.await?;
@@ -118,7 +128,13 @@ pub async fn create_token(
)]
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
r#"
SELECT t.id, t.name, t.scope, t.prefix, t.user_id, u.username,
t.last_used_at, t.revoked_at, t.created_at
FROM api_tokens t
LEFT JOIN users u ON u.id = t.user_id
ORDER BY t.created_at DESC
"#,
)
.fetch_all(&state.pool)
.await?;
@@ -130,6 +146,8 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
name: row.get("name"),
scope: row.get("scope"),
prefix: row.get("prefix"),
user_id: row.get("user_id"),
username: row.get("username"),
last_used_at: row.get("last_used_at"),
revoked_at: row.get("revoked_at"),
created_at: row.get("created_at"),
@@ -171,6 +189,47 @@ pub async fn revoke_token(
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateTokenRequest {
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
}
/// Update a token's assigned user
#[utoipa::path(
patch,
path = "/admin/tokens/{id}",
tag = "tokens",
params(
("id" = String, Path, description = "Token UUID"),
),
request_body = UpdateTokenRequest,
responses(
(status = 200, description = "Token updated"),
(status = 404, description = "Token not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_token(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTokenRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
.bind(input.user_id)
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("token not found"));
}
Ok(Json(serde_json::json!({"updated": true, "id": id})))
}
/// Permanently delete a revoked API token
#[utoipa::path(
post,

195
apps/api/src/users.rs Normal file
View File

@@ -0,0 +1,195 @@
use axum::{extract::{Path, State}, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct UserResponse {
#[schema(value_type = String)]
pub id: Uuid,
pub username: String,
pub token_count: i64,
pub books_read: i64,
pub books_reading: i64,
#[schema(value_type = String)]
pub created_at: DateTime<Utc>,
}
#[derive(Deserialize, ToSchema)]
pub struct CreateUserRequest {
pub username: String,
}
/// List all reader users with their associated token count
#[utoipa::path(
get,
path = "/admin/users",
tag = "users",
responses(
(status = 200, body = Vec<UserResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn list_users(State(state): State<AppState>) -> Result<Json<Vec<UserResponse>>, ApiError> {
let rows = sqlx::query(
r#"
SELECT u.id, u.username, u.created_at,
COUNT(DISTINCT t.id) AS token_count,
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read,
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'reading') AS books_reading
FROM users u
LEFT JOIN api_tokens t ON t.user_id = u.id AND t.revoked_at IS NULL
LEFT JOIN book_reading_progress brp ON brp.user_id = u.id
GROUP BY u.id, u.username, u.created_at
ORDER BY u.created_at DESC
"#,
)
.fetch_all(&state.pool)
.await?;
let items = rows
.into_iter()
.map(|row| UserResponse {
id: row.get("id"),
username: row.get("username"),
token_count: row.get("token_count"),
books_read: row.get("books_read"),
books_reading: row.get("books_reading"),
created_at: row.get("created_at"),
})
.collect();
Ok(Json(items))
}
/// Create a new reader user
#[utoipa::path(
post,
path = "/admin/users",
tag = "users",
request_body = CreateUserRequest,
responses(
(status = 200, body = UserResponse, description = "User created"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn create_user(
State(state): State<AppState>,
Json(input): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>, ApiError> {
if input.username.trim().is_empty() {
return Err(ApiError::bad_request("username is required"));
}
let id = Uuid::new_v4();
let row = sqlx::query(
"INSERT INTO users (id, username) VALUES ($1, $2) RETURNING id, username, created_at",
)
.bind(id)
.bind(input.username.trim())
.fetch_one(&state.pool)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.constraint() == Some("users_username_key") {
return ApiError::bad_request("username already exists");
}
}
ApiError::from(e)
})?;
Ok(Json(UserResponse {
id: row.get("id"),
username: row.get("username"),
token_count: 0,
books_read: 0,
books_reading: 0,
created_at: row.get("created_at"),
}))
}
/// Update a reader user's username
#[utoipa::path(
patch,
path = "/admin/users/{id}",
tag = "users",
request_body = CreateUserRequest,
responses(
(status = 200, body = UserResponse, description = "User updated"),
(status = 400, description = "Invalid input"),
(status = 404, description = "User not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<CreateUserRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
if input.username.trim().is_empty() {
return Err(ApiError::bad_request("username is required"));
}
let result = sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
.bind(input.username.trim())
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.constraint() == Some("users_username_key") {
return ApiError::bad_request("username already exists");
}
}
ApiError::from(e)
})?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("user not found"));
}
Ok(Json(serde_json::json!({"updated": true, "id": id})))
}
/// Delete a reader user (cascades on tokens and reading progress)
#[utoipa::path(
delete,
path = "/admin/users/{id}",
tag = "users",
params(
("id" = String, Path, description = "User UUID"),
),
responses(
(status = 200, description = "User deleted"),
(status = 404, description = "User not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn delete_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("user not found"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
}

View File

@@ -0,0 +1,135 @@
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { BooksGrid } from "@/app/components/BookCard";
import { OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function AuthorDetailPage({
params,
searchParams,
}: {
params: Promise<{ name: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { t } = await getServerTranslations();
const { name: encodedName } = await params;
const authorName = decodeURIComponent(encodedName);
const searchParamsAwaited = await searchParams;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
// Fetch books by this author (server-side filtering via API) and series by this author
const [booksPage, seriesPage] = await Promise.all([
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
() => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto
),
fetchAllSeries(undefined, undefined, undefined, 1, 200, undefined, undefined, undefined, undefined, authorName).catch(
() => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto
),
]);
const totalPages = Math.ceil(booksPage.total / limit);
const authorSeries = seriesPage.items;
return (
<>
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-6">
<Link href="/authors" className="hover:text-foreground transition-colors">
{t("authors.title")}
</Link>
<span>/</span>
<span className="text-foreground font-medium">{authorName}</span>
</nav>
{/* Author Header */}
<div className="flex items-center gap-4 mb-8">
<div className="w-16 h-16 rounded-full bg-accent/50 flex items-center justify-center flex-shrink-0">
<span className="text-2xl font-bold text-accent-foreground">
{authorName.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h1 className="text-3xl font-bold text-foreground">{authorName}</h1>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm text-muted-foreground">
{t("authors.bookCount", { count: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
</span>
{authorSeries.length > 0 && (
<span className="text-sm text-muted-foreground">
{t("authors.seriesCount", { count: String(authorSeries.length), plural: authorSeries.length !== 1 ? "s" : "" })}
</span>
)}
</div>
</div>
</div>
{/* Series Section */}
{authorSeries.length > 0 && (
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-4">
{t("authors.seriesBy", { name: authorName })}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{authorSeries.map((s) => (
<Link
key={`${s.library_id}-${s.name}`}
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
className="group"
>
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200">
<div className="aspect-[2/3] relative bg-muted/50">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={s.name}
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
/>
</div>
<div className="p-3">
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name}
</h3>
<p className="text-xs text-muted-foreground mt-1">
{t("authors.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
</p>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{/* Books Section */}
{booksPage.items.length > 0 && (
<section>
<h2 className="text-xl font-semibold text-foreground mb-4">
{t("authors.booksBy", { name: authorName })}
</h2>
<BooksGrid books={booksPage.items} />
<OffsetPagination
currentPage={page}
totalPages={totalPages}
pageSize={limit}
totalItems={booksPage.total}
/>
</section>
)}
{/* Empty State */}
{booksPage.items.length === 0 && authorSeries.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-muted-foreground text-lg">
{t("authors.noResults")}
</p>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,122 @@
import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function AuthorsPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { t } = await getServerTranslations();
const searchParamsAwaited = await searchParams;
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
const authorsPage = await fetchAuthors(
searchQuery || undefined,
page,
limit,
sort,
).catch(() => ({ items: [], total: 0, page: 1, limit }) as AuthorsPageDto);
const totalPages = Math.ceil(authorsPage.total / limit);
const hasFilters = searchQuery || sort;
const sortOptions = [
{ value: "", label: t("authors.sortName") },
{ value: "books", label: t("authors.sortBooks") },
];
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{t("authors.title")}
</h1>
</div>
<Card className="mb-6">
<CardContent className="pt-6">
<LiveSearchForm
basePath="/authors"
fields={[
{ name: "q", type: "text", label: t("common.search"), placeholder: t("authors.searchPlaceholder") },
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
]}
/>
</CardContent>
</Card>
{/* Results count */}
<p className="text-sm text-muted-foreground mb-4">
{authorsPage.total} {t("authors.title").toLowerCase()}
{searchQuery && <> {t("authors.matchingQuery")} &quot;{searchQuery}&quot;</>}
</p>
{/* Authors List */}
{authorsPage.items.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{authorsPage.items.map((author) => (
<Link
key={author.name}
href={`/authors/${encodeURIComponent(author.name)}`}
className="group"
>
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/50 flex items-center justify-center flex-shrink-0">
<span className="text-lg font-semibold text-violet-500">
{author.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<h3 className="font-medium text-foreground truncate text-sm group-hover:text-violet-500 transition-colors" title={author.name}>
{author.name}
</h3>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-muted-foreground">
{t("authors.bookCount", { count: String(author.book_count), plural: author.book_count !== 1 ? "s" : "" })}
</span>
<span className="text-xs text-muted-foreground">
{t("authors.seriesCount", { count: String(author.series_count), plural: author.series_count !== 1 ? "s" : "" })}
</span>
</div>
</div>
</div>
</div>
</Link>
))}
</div>
<OffsetPagination
currentPage={page}
totalPages={totalPages}
pageSize={limit}
totalItems={authorsPage.total}
/>
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<p className="text-muted-foreground text-lg">
{hasFilters ? t("authors.noResults") : t("authors.noAuthors")}
</p>
</div>
)}
</>
);
}

View File

@@ -1,19 +1,24 @@
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton";
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
import { EditBookForm } from "../../components/EditBookForm";
import { SafeHtml } from "../../components/SafeHtml";
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
import { BookPreview } from "@/app/components/BookPreview";
import { ConvertButton } from "@/app/components/ConvertButton";
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic";
import { SafeHtml } from "@/app/components/SafeHtml";
import { getServerTranslations } from "@/lib/i18n/server";
import Image from "next/image";
import Link from "next/link";
const EditBookForm = nextDynamic(
() => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
);
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
const readingStatusConfig: Record<ReadingStatus, { label: string; className: string }> = {
unread: { label: "Non lu", className: "bg-muted/60 text-muted-foreground border border-border" },
reading: { label: "En cours", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30" },
read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" },
const readingStatusClassNames: Record<ReadingStatus, string> = {
unread: "bg-muted/60 text-muted-foreground border border-border",
reading: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30",
read: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30",
};
async function fetchBook(bookId: string): Promise<BookDto | null> {
@@ -39,6 +44,8 @@ export default async function BookDetailPage({
notFound();
}
const { t, locale } = await getServerTranslations();
const library = libraries.find(l => l.id === book.library_id);
const formatBadge = (book.format ?? book.kind).toUpperCase();
const formatColor =
@@ -46,14 +53,15 @@ export default async function BookDetailPage({
formatBadge === "CBR" ? "bg-warning/10 text-warning border-warning/30" :
formatBadge === "PDF" ? "bg-destructive/10 text-destructive border-destructive/30" :
"bg-muted/50 text-muted-foreground border-border";
const { label: statusLabel, className: statusClassName } = readingStatusConfig[book.reading_status];
const statusLabel = t(`status.${book.reading_status}` as "status.unread" | "status.reading" | "status.read");
const statusClassName = readingStatusClassNames[book.reading_status];
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm">
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
Libraries
{t("bookDetail.libraries")}
</Link>
<span className="text-muted-foreground">/</span>
{library && (
@@ -88,10 +96,10 @@ export default async function BookDetailPage({
<div className="w-48 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
<Image
src={getBookCoverUrl(book.id)}
alt={`Cover of ${book.title}`}
alt={t("bookDetail.coverOf", { title: book.title })}
fill
className="object-cover"
unoptimized
sizes="192px"
loading="lazy"
/>
</div>
@@ -134,7 +142,7 @@ export default async function BookDetailPage({
</span>
{book.reading_last_read_at && (
<span className="text-xs text-muted-foreground">
{new Date(book.reading_last_read_at).toLocaleDateString()}
{new Date(book.reading_last_read_at).toLocaleDateString(locale)}
</span>
)}
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
@@ -148,7 +156,7 @@ export default async function BookDetailPage({
</span>
{book.page_count && (
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{book.page_count} pages
{book.page_count} {t("dashboard.pages").toLowerCase()}
</span>
)}
{book.language && (
@@ -181,24 +189,24 @@ export default async function BookDetailPage({
<svg className="w-3.5 h-3.5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Informations techniques
{t("bookDetail.technicalInfo")}
</summary>
<div className="mt-3 p-4 rounded-lg bg-muted/30 border border-border/50 space-y-2 text-xs">
{book.file_path && (
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground">Fichier</span>
<span className="text-muted-foreground">{t("bookDetail.file")}</span>
<code className="font-mono text-foreground break-all">{book.file_path}</code>
</div>
)}
{book.file_format && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Format fichier</span>
<span className="text-muted-foreground">{t("bookDetail.fileFormat")}</span>
<span className="text-foreground">{book.file_format.toUpperCase()}</span>
</div>
)}
{book.file_parse_status && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Parsing</span>
<span className="text-muted-foreground">{t("bookDetail.parsing")}</span>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
book.file_parse_status === "success" ? "bg-success/10 text-success" :
book.file_parse_status === "failed" ? "bg-destructive/10 text-destructive" :
@@ -218,8 +226,8 @@ export default async function BookDetailPage({
</div>
{book.updated_at && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Mis à jour</span>
<span className="text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
<span className="text-muted-foreground">{t("bookDetail.updatedAt")}</span>
<span className="text-foreground">{new Date(book.updated_at).toLocaleString(locale)}</span>
</div>
)}
</div>

View File

@@ -1,9 +1,10 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard";
import { LiveSearchForm } from "../components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui";
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link";
import Image from "next/image";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
@@ -12,10 +13,13 @@ export default async function BooksPage({
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { t } = await getServerTranslations();
const searchParamsAwaited = await searchParams;
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
@@ -60,7 +64,7 @@ export default async function BooksPage({
totalHits = searchResponse.estimated_total_hits;
}
} else {
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort).catch(() => ({
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
items: [] as BookDto[],
total: 0,
page: 1,
@@ -78,23 +82,37 @@ export default async function BooksPage({
const totalPages = Math.ceil(total / limit);
const libraryOptions = [
{ value: "", label: "All libraries" },
{ value: "", label: t("books.allLibraries") },
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
];
const statusOptions = [
{ value: "", label: "All" },
{ value: "unread", label: "Unread" },
{ value: "reading", label: "In progress" },
{ value: "read", label: "Read" },
{ value: "", label: t("common.all") },
{ value: "unread", label: t("status.unread") },
{ value: "reading", label: t("status.reading") },
{ value: "read", label: t("status.read") },
];
const formatOptions = [
{ value: "", label: t("books.allFormats") },
{ value: "cbz", label: "CBZ" },
{ value: "cbr", label: "CBR" },
{ value: "pdf", label: "PDF" },
{ value: "epub", label: "EPUB" },
];
const metadataOptions = [
{ value: "", label: t("series.metadataAll") },
{ value: "linked", label: t("series.metadataLinked") },
{ value: "unlinked", label: t("series.metadataUnlinked") },
];
const sortOptions = [
{ value: "", label: "Title" },
{ value: "latest", label: "Latest added" },
{ value: "", label: t("books.sortTitle") },
{ value: "latest", label: t("books.sortLatest") },
];
const hasFilters = searchQuery || libraryId || readingStatus || sort;
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
return (
<>
@@ -103,7 +121,7 @@ export default async function BooksPage({
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Books
{t("books.title")}
</h1>
</div>
@@ -112,10 +130,12 @@ export default async function BooksPage({
<LiveSearchForm
basePath="/books"
fields={[
{ name: "q", type: "text", label: "Search", placeholder: "Search by title, author, series...", className: "flex-1 w-full" },
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
{ name: "sort", type: "select", label: "Sort", options: sortOptions, className: "w-full sm:w-40" },
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
{ name: "format", type: "select", label: t("books.format"), options: formatOptions },
{ name: "metadata", type: "select", label: t("series.metadata"), options: metadataOptions },
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
]}
/>
</CardContent>
@@ -124,18 +144,18 @@ export default async function BooksPage({
{/* Résultats */}
{searchQuery && totalHits !== null ? (
<p className="text-sm text-muted-foreground mb-4">
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for &quot;{searchQuery}&quot;
{t("books.resultCountFor", { count: String(totalHits), plural: totalHits !== 1 ? "s" : "", query: searchQuery })}
</p>
) : !searchQuery && (
<p className="text-sm text-muted-foreground mb-4">
{total} book{total !== 1 ? 's' : ''}
{t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })}
</p>
)}
{/* Séries matchantes */}
{seriesHits.length > 0 && (
<div className="mb-8">
<h2 className="text-lg font-semibold text-foreground mb-3">Series</h2>
<h2 className="text-lg font-semibold text-foreground mb-3">{t("books.seriesHeading")}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{seriesHits.map((s) => (
<Link
@@ -147,18 +167,18 @@ export default async function BooksPage({
<div className="aspect-[2/3] relative bg-muted/50">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={`Cover of ${s.name}`}
alt={t("books.coverOf", { name: s.name })}
fill
className="object-cover"
unoptimized
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
/>
</div>
<div className="p-2">
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name === "unclassified" ? "Unclassified" : s.name}
{s.name === "unclassified" ? t("books.unclassified") : s.name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
{t("books.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
</p>
</div>
</div>
@@ -171,7 +191,7 @@ export default async function BooksPage({
{/* Grille de livres */}
{displayBooks.length > 0 ? (
<>
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">Books</h2>}
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>}
<BooksGrid books={displayBooks} />
{!searchQuery && (
@@ -184,7 +204,7 @@ export default async function BooksPage({
)}
</>
) : (
<EmptyState message={searchQuery ? `No books found for "${searchQuery}"` : "No books available"} />
<EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} />
)}
</>
);

View File

@@ -0,0 +1,809 @@
export const dynamic = "force-dynamic";
import { notFound } from "next/navigation";
import Link from "next/link";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "@/lib/api";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
StatusBadge, JobTypeBadge, StatBox, ProgressBar
} from "@/app/components/ui";
import { JobDetailLive } from "@/app/components/JobDetailLive";
import { getServerTranslations } from "@/lib/i18n/server";
interface JobDetailPageProps {
params: Promise<{ id: string }>;
}
interface JobDetails {
id: string;
library_id: string | null;
book_id: string | null;
type: string;
status: string;
created_at: string;
started_at: string | null;
finished_at: string | null;
phase2_started_at: string | null;
generating_thumbnails_started_at: string | null;
current_file: string | null;
progress_percent: number | null;
processed_files: number | null;
total_files: number | null;
stats_json: {
scanned_files: number;
indexed_files: number;
removed_files: number;
errors: number;
warnings: number;
} | null;
error_opt: string | null;
}
interface JobError {
id: string;
file_path: string;
error_message: string;
created_at: string;
}
async function getJobDetails(jobId: string): Promise<JobDetails | null> {
try {
return await apiFetch<JobDetails>(`/index/jobs/${jobId}`);
} catch {
return null;
}
}
async function getJobErrors(jobId: string): Promise<JobError[]> {
try {
return await apiFetch<JobError[]>(`/index/jobs/${jobId}/errors`);
} catch {
return [];
}
}
function formatDuration(start: string, end: string | null): string {
const startDate = new Date(start);
const endDate = end ? new Date(end) : new Date();
const diff = endDate.getTime() - startDate.getTime();
if (diff < 60000) return `${Math.floor(diff / 1000)}s`;
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ${Math.floor((diff % 60000) / 1000)}s`;
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
}
function formatSpeed(count: number, durationMs: number): string {
if (durationMs === 0 || count === 0) return "-";
return `${(count / (durationMs / 1000)).toFixed(1)}/s`;
}
export default async function JobDetailPage({ params }: JobDetailPageProps) {
const { id } = await params;
const [job, errors] = await Promise.all([
getJobDetails(id),
getJobErrors(id),
]);
if (!job) {
notFound();
}
const { t, locale } = await getServerTranslations();
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
rebuild: {
label: t("jobType.rebuildLabel"),
description: t("jobType.rebuildDesc"),
isThumbnailOnly: false,
},
full_rebuild: {
label: t("jobType.full_rebuildLabel"),
description: t("jobType.full_rebuildDesc"),
isThumbnailOnly: false,
},
rescan: {
label: t("jobType.rescanLabel"),
description: t("jobType.rescanDesc"),
isThumbnailOnly: false,
},
thumbnail_rebuild: {
label: t("jobType.thumbnail_rebuildLabel"),
description: t("jobType.thumbnail_rebuildDesc"),
isThumbnailOnly: true,
},
thumbnail_regenerate: {
label: t("jobType.thumbnail_regenerateLabel"),
description: t("jobType.thumbnail_regenerateDesc"),
isThumbnailOnly: true,
},
cbr_to_cbz: {
label: t("jobType.cbr_to_cbzLabel"),
description: t("jobType.cbr_to_cbzDesc"),
isThumbnailOnly: false,
},
metadata_batch: {
label: t("jobType.metadata_batchLabel"),
description: t("jobType.metadata_batchDesc"),
isThumbnailOnly: false,
},
metadata_refresh: {
label: t("jobType.metadata_refreshLabel"),
description: t("jobType.metadata_refreshDesc"),
isThumbnailOnly: false,
},
};
const isMetadataBatch = job.type === "metadata_batch";
const isMetadataRefresh = job.type === "metadata_refresh";
// Fetch batch report & results for metadata_batch jobs
let batchReport: MetadataBatchReportDto | null = null;
let batchResults: MetadataBatchResultDto[] = [];
if (isMetadataBatch) {
[batchReport, batchResults] = await Promise.all([
getMetadataBatchReport(id).catch(() => null),
getMetadataBatchResults(id).catch(() => []),
]);
}
// Fetch refresh report for metadata_refresh jobs
let refreshReport: MetadataRefreshReportDto | null = null;
if (isMetadataRefresh) {
refreshReport = await getMetadataRefreshReport(id).catch(() => null);
}
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
label: job.type,
description: null,
isThumbnailOnly: false,
};
const durationMs = job.started_at
? new Date(job.finished_at || new Date()).getTime() - new Date(job.started_at).getTime()
: 0;
const isCompleted = job.status === "success";
const isFailed = job.status === "failed";
const isCancelled = job.status === "cancelled";
const isTerminal = isCompleted || isFailed || isCancelled;
const isExtractingPages = job.status === "extracting_pages";
const isThumbnailPhase = job.status === "generating_thumbnails";
const isPhase2 = isExtractingPages || isThumbnailPhase;
const { isThumbnailOnly } = typeInfo;
// Which label to use for the progress card
const progressTitle = isMetadataBatch
? t("jobDetail.metadataSearch")
: isMetadataRefresh
? t("jobDetail.metadataRefresh")
: isThumbnailOnly
? t("jobType.thumbnail_rebuild")
: isExtractingPages
? t("jobDetail.phase2a")
: isThumbnailPhase
? t("jobDetail.phase2b")
: t("jobDetail.phase1");
const progressDescription = isMetadataBatch
? t("jobDetail.metadataSearchDesc")
: isMetadataRefresh
? t("jobDetail.metadataRefreshDesc")
: isThumbnailOnly
? undefined
: isExtractingPages
? t("jobDetail.phase2aDesc")
: isThumbnailPhase
? t("jobDetail.phase2bDesc")
: t("jobDetail.phase1Desc");
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
const speedCount = isThumbnailOnly
? (job.processed_files ?? 0)
: (job.stats_json?.scanned_files ?? 0);
const showProgressCard =
(isCompleted || isFailed || job.status === "running" || isPhase2) &&
(job.total_files != null || !!job.current_file);
return (
<>
<JobDetailLive jobId={id} isTerminal={isTerminal} />
<div className="mb-6">
<Link
href="/jobs"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t("jobDetail.backToJobs")}
</Link>
<h1 className="text-3xl font-bold text-foreground mt-2">{t("jobDetail.title")}</h1>
</div>
{/* Summary banner — completed */}
{isCompleted && job.started_at && (
<div className="mb-6 p-4 rounded-xl bg-success/10 border border-success/30 flex items-start gap-3">
<svg className="w-5 h-5 text-success mt-0.5 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="text-sm text-success">
<span className="font-semibold">{t("jobDetail.completedIn", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
{isMetadataBatch && batchReport && (
<span className="ml-2 text-success/80">
{batchReport.auto_matched} {t("jobDetail.autoMatched").toLowerCase()}, {batchReport.already_linked} {t("jobDetail.alreadyLinked").toLowerCase()}, {batchReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {batchReport.errors} {t("jobDetail.errors").toLowerCase()}
</span>
)}
{isMetadataRefresh && refreshReport && (
<span className="ml-2 text-success/80">
{refreshReport.refreshed} {t("jobDetail.refreshed").toLowerCase()}, {refreshReport.unchanged} {t("jobDetail.unchanged").toLowerCase()}, {refreshReport.errors} {t("jobDetail.errors").toLowerCase()}
</span>
)}
{!isMetadataBatch && !isMetadataRefresh && job.stats_json && (
<span className="ml-2 text-success/80">
{job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} ${t("jobDetail.errors").toLowerCase()}`}
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
</span>
)}
{!isMetadataBatch && !isMetadataRefresh && !job.stats_json && isThumbnailOnly && job.total_files != null && (
<span className="ml-2 text-success/80">
{job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
</span>
)}
</div>
</div>
)}
{/* Summary banner — failed */}
{isFailed && (
<div className="mb-6 p-4 rounded-xl bg-destructive/10 border border-destructive/30 flex items-start gap-3">
<svg className="w-5 h-5 text-destructive mt-0.5 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>
<div className="text-sm text-destructive">
<span className="font-semibold">{t("jobDetail.jobFailed")}</span>
{job.started_at && (
<span className="ml-2 text-destructive/80">{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
)}
{job.error_opt && (
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
)}
</div>
</div>
)}
{/* Summary banner — cancelled */}
{isCancelled && (
<div className="mb-6 p-4 rounded-xl bg-muted border border-border flex items-start gap-3">
<svg className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<span className="text-sm text-muted-foreground">
<span className="font-semibold">{t("jobDetail.cancelled")}</span>
{job.started_at && (
<span className="ml-2">{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
)}
</span>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Overview Card */}
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.overview")}</CardTitle>
{typeInfo.description && (
<CardDescription>{typeInfo.description}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">ID</span>
<code className="px-2 py-1 bg-muted rounded font-mono text-sm text-foreground">{job.id}</code>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">{t("jobsList.type")}</span>
<div className="flex items-center gap-2">
<JobTypeBadge type={job.type} />
<span className="text-sm text-muted-foreground">{typeInfo.label}</span>
</div>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">{t("jobsList.status")}</span>
<StatusBadge status={job.status} />
</div>
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
<span className="text-sm text-muted-foreground">{t("jobDetail.library")}</span>
<span className="text-sm text-foreground">{job.library_id || t("jobDetail.allLibraries")}</span>
</div>
{job.book_id && (
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
<span className="text-sm text-muted-foreground">{t("jobDetail.book")}</span>
<Link
href={`/books/${job.book_id}`}
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
>
{job.book_id.slice(0, 8)}
</Link>
</div>
)}
{job.started_at && (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">{t("jobsList.duration")}</span>
<span className="text-sm font-semibold text-foreground">
{formatDuration(job.started_at, job.finished_at)}
</span>
</div>
)}
</CardContent>
</Card>
{/* Timeline Card */}
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.timeline")}</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
{/* Vertical line */}
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border" />
<div className="space-y-5">
{/* Created */}
<div className="flex items-start gap-4">
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-muted border-2 border-border shrink-0 z-10" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">{t("jobDetail.created")}</span>
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString(locale)}</p>
</div>
</div>
{/* Phase 1 start — for index jobs that have two phases */}
{job.started_at && job.phase2_started_at && (
<div className="flex items-start gap-4">
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">{t("jobDetail.phase1")}</span>
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString(locale)}</p>
<p className="text-xs text-primary/80 font-medium mt-0.5">
{t("jobDetail.duration", { duration: formatDuration(job.started_at, job.phase2_started_at) })}
{job.stats_json && (
<span className="text-muted-foreground font-normal ml-1">
· {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
</span>
)}
</p>
</div>
</div>
)}
{/* Phase 2a — Extracting pages (index jobs with phase2) */}
{job.phase2_started_at && !isThumbnailOnly && (
<div className="flex items-start gap-4">
<div className={`w-3.5 h-3.5 rounded-full mt-0.5 shrink-0 z-10 ${
job.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">{t("jobDetail.phase2a")}</span>
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString(locale)}</p>
<p className="text-xs text-primary/80 font-medium mt-0.5">
{t("jobDetail.duration", { duration: formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null) })}
{!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
<span className="text-muted-foreground font-normal ml-1">· {t("jobDetail.inProgress")}</span>
)}
</p>
</div>
</div>
)}
{/* Phase 2b — Generating thumbnails */}
{(job.generating_thumbnails_started_at || (job.phase2_started_at && isThumbnailOnly)) && (
<div className="flex items-start gap-4">
<div className={`w-3.5 h-3.5 rounded-full mt-0.5 shrink-0 z-10 ${
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">
{isThumbnailOnly ? t("jobType.thumbnail_rebuild") : t("jobDetail.phase2b")}
</span>
<p className="text-xs text-muted-foreground">
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString(locale)}
</p>
{(job.generating_thumbnails_started_at || job.finished_at) && (
<p className="text-xs text-primary/80 font-medium mt-0.5">
{t("jobDetail.duration", { duration: formatDuration(
job.generating_thumbnails_started_at ?? job.phase2_started_at!,
job.finished_at ?? null
) })}
{job.total_files != null && job.total_files > 0 && (
<span className="text-muted-foreground font-normal ml-1">
· {job.processed_files ?? job.total_files} {t("jobType.thumbnail_rebuild").toLowerCase()}
</span>
)}
</p>
)}
{!job.finished_at && isThumbnailPhase && (
<span className="text-xs text-muted-foreground">{t("jobDetail.inProgress")}</span>
)}
</div>
</div>
)}
{/* Started — for jobs without phase2 (cbr_to_cbz, or no phase yet) */}
{job.started_at && !job.phase2_started_at && (
<div className="flex items-start gap-4">
<div className={`w-3.5 h-3.5 rounded-full mt-0.5 shrink-0 z-10 ${
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">{t("jobDetail.started")}</span>
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString(locale)}</p>
</div>
</div>
)}
{/* Pending — not started yet */}
{!job.started_at && (
<div className="flex items-start gap-4">
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">{t("jobDetail.pendingStart")}</span>
</div>
</div>
)}
{/* Finished */}
{job.finished_at && (
<div className="flex items-start gap-4">
<div className={`w-3.5 h-3.5 rounded-full mt-0.5 shrink-0 z-10 ${
isCompleted ? "bg-success" : isFailed ? "bg-destructive" : "bg-muted"
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">
{isCompleted ? t("jobDetail.finished") : isFailed ? t("jobDetail.failed") : t("jobDetail.cancelled")}
</span>
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString(locale)}</p>
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Progress Card */}
{showProgressCard && (
<Card>
<CardHeader>
<CardTitle>{progressTitle}</CardTitle>
{progressDescription && <CardDescription>{progressDescription}</CardDescription>}
</CardHeader>
<CardContent>
{job.total_files != null && job.total_files > 0 && (
<>
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
<div className="grid grid-cols-3 gap-4">
<StatBox
value={job.processed_files ?? 0}
label={isThumbnailOnly || isPhase2 ? t("jobDetail.generated") : t("jobDetail.processed")}
variant="primary"
/>
<StatBox value={job.total_files} label={t("jobDetail.total")} />
<StatBox
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
label={t("jobDetail.remaining")}
variant={isCompleted ? "default" : "warning"}
/>
</div>
</>
)}
{job.current_file && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{t("jobDetail.currentFile")}</span>
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
</div>
)}
</CardContent>
</Card>
)}
{/* Index Statistics — index jobs only */}
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.indexStats")}</CardTitle>
{job.started_at && (
<CardDescription>
{formatDuration(job.started_at, job.finished_at)}
{speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} scan rate`}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
<StatBox value={job.stats_json.scanned_files} label={t("jobDetail.scanned")} variant="success" />
<StatBox value={job.stats_json.indexed_files} label={t("jobDetail.indexed")} variant="primary" />
<StatBox value={job.stats_json.removed_files} label={t("jobDetail.removed")} variant="warning" />
<StatBox value={job.stats_json.warnings ?? 0} label={t("jobDetail.warnings")} variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
<StatBox value={job.stats_json.errors} label={t("jobDetail.errors")} variant={job.stats_json.errors > 0 ? "error" : "default"} />
</div>
</CardContent>
</Card>
)}
{/* Thumbnail statistics — thumbnail-only jobs, completed */}
{isThumbnailOnly && isCompleted && job.total_files != null && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.thumbnailStats")}</CardTitle>
{job.started_at && (
<CardDescription>
{formatDuration(job.started_at, job.finished_at)}
{speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} thumbnails/s`}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<StatBox value={job.processed_files ?? job.total_files} label={t("jobDetail.generated")} variant="success" />
<StatBox value={job.total_files} label={t("jobDetail.total")} />
</div>
</CardContent>
</Card>
)}
{/* Metadata batch report */}
{isMetadataBatch && batchReport && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.batchReport")}</CardTitle>
<CardDescription>{t("jobDetail.seriesAnalyzed", { count: String(batchReport.total_series) })}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<StatBox value={batchReport.auto_matched} label={t("jobDetail.autoMatched")} variant="success" />
<StatBox value={batchReport.already_linked} label={t("jobDetail.alreadyLinked")} variant="primary" />
<StatBox value={batchReport.no_results} label={t("jobDetail.noResults")} />
<StatBox value={batchReport.too_many_results} label={t("jobDetail.tooManyResults")} variant="warning" />
<StatBox value={batchReport.low_confidence} label={t("jobDetail.lowConfidence")} variant="warning" />
<StatBox value={batchReport.errors} label={t("jobDetail.errors")} variant={batchReport.errors > 0 ? "error" : "default"} />
</div>
</CardContent>
</Card>
)}
{/* Metadata refresh report */}
{isMetadataRefresh && refreshReport && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.refreshReport")}</CardTitle>
<CardDescription>{t("jobDetail.refreshReportDesc", { count: String(refreshReport.total_links) })}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatBox
value={refreshReport.refreshed}
label={t("jobDetail.refreshed")}
variant="success"
icon={
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
/>
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
</div>
</CardContent>
</Card>
)}
{/* Metadata refresh changes detail */}
{isMetadataRefresh && refreshReport && refreshReport.changes.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>{t("jobDetail.refreshChanges")}</CardTitle>
<CardDescription>{t("jobDetail.refreshChangesDesc", { count: String(refreshReport.changes.length) })}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 max-h-[600px] overflow-y-auto">
{refreshReport.changes.map((r, idx) => (
<div
key={idx}
className={`p-3 rounded-lg border ${
r.status === "updated" ? "bg-success/10 border-success/20" :
r.status === "error" ? "bg-destructive/10 border-destructive/20" :
"bg-muted/50 border-border/60"
}`}
>
<div className="flex items-center justify-between gap-2">
{job.library_id ? (
<Link
href={`/libraries/${job.library_id}/series/${encodeURIComponent(r.series_name)}`}
className="font-medium text-sm text-primary hover:underline truncate"
>
{r.series_name}
</Link>
) : (
<span className="font-medium text-sm text-foreground truncate">{r.series_name}</span>
)}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">{r.provider}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap ${
r.status === "updated" ? "bg-success/20 text-success" :
r.status === "error" ? "bg-destructive/20 text-destructive" :
"bg-muted text-muted-foreground"
}`}>
{r.status === "updated" ? t("jobDetail.refreshed") :
r.status === "error" ? t("common.error") :
t("jobDetail.unchanged")}
</span>
</div>
</div>
{r.error && (
<p className="text-xs text-destructive/80 mt-1">{r.error}</p>
)}
{/* Series field changes */}
{r.series_changes.length > 0 && (
<div className="mt-2">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground font-semibold">{t("metadata.seriesLabel")}</span>
<div className="mt-1 space-y-1">
{r.series_changes.map((c, ci) => (
<div key={ci} className="flex items-start gap-2 text-xs">
<span className="font-medium text-foreground shrink-0 w-24">{t(`field.${c.field}` as never) || c.field}</span>
<span className="text-muted-foreground line-through truncate max-w-[200px]" title={String(c.old ?? "—")}>
{c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old)) : "—"}
</span>
<span className="text-success shrink-0"></span>
<span className="text-success truncate max-w-[200px]" title={String(c.new ?? "—")}>
{c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new)) : "—"}
</span>
</div>
))}
</div>
</div>
)}
{/* Book field changes */}
{r.book_changes.length > 0 && (
<div className="mt-2">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground font-semibold">
{t("metadata.booksLabel")} ({r.book_changes.length})
</span>
<div className="mt-1 space-y-2">
{r.book_changes.map((b, bi) => (
<div key={bi} className="pl-2 border-l-2 border-border/60">
<Link
href={`/books/${b.book_id}`}
className="text-xs text-primary hover:underline font-medium"
>
{b.volume != null && <span className="text-muted-foreground mr-1">T.{b.volume}</span>}
{b.title}
</Link>
<div className="mt-0.5 space-y-0.5">
{b.changes.map((c, ci) => (
<div key={ci} className="flex items-start gap-2 text-xs">
<span className="font-medium text-foreground shrink-0 w-24">{t(`field.${c.field}` as never) || c.field}</span>
<span className="text-muted-foreground line-through truncate max-w-[150px]" title={String(c.old ?? "—")}>
{c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old).substring(0, 60)) : "—"}
</span>
<span className="text-success shrink-0"></span>
<span className="text-success truncate max-w-[150px]" title={String(c.new ?? "—")}>
{c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new).substring(0, 60)) : "—"}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* Metadata batch results */}
{isMetadataBatch && batchResults.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>{t("jobDetail.resultsBySeries")}</CardTitle>
<CardDescription>{t("jobDetail.seriesProcessed", { count: String(batchResults.length) })}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 max-h-[600px] overflow-y-auto">
{batchResults.map((r) => (
<div
key={r.id}
className={`p-3 rounded-lg border ${
r.status === "auto_matched" ? "bg-success/10 border-success/20" :
r.status === "already_linked" ? "bg-primary/10 border-primary/20" :
r.status === "error" ? "bg-destructive/10 border-destructive/20" :
"bg-muted/50 border-border/60"
}`}
>
<div className="flex items-center justify-between gap-2">
{job.library_id ? (
<Link
href={`/libraries/${job.library_id}/series/${encodeURIComponent(r.series_name)}`}
className="font-medium text-sm text-primary hover:underline truncate"
>
{r.series_name}
</Link>
) : (
<span className="font-medium text-sm text-foreground truncate">{r.series_name}</span>
)}
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap ${
r.status === "auto_matched" ? "bg-success/20 text-success" :
r.status === "already_linked" ? "bg-primary/20 text-primary" :
r.status === "no_results" ? "bg-muted text-muted-foreground" :
r.status === "too_many_results" ? "bg-amber-500/15 text-amber-600" :
r.status === "low_confidence" ? "bg-amber-500/15 text-amber-600" :
r.status === "error" ? "bg-destructive/20 text-destructive" :
"bg-muted text-muted-foreground"
}`}>
{r.status === "auto_matched" ? t("jobDetail.autoMatched") :
r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
r.status === "no_results" ? t("jobDetail.noResults") :
r.status === "too_many_results" ? t("jobDetail.tooManyResults") :
r.status === "low_confidence" ? t("jobDetail.lowConfidence") :
r.status === "error" ? t("common.error") :
r.status}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{r.provider_used && (
<span>{r.provider_used}{r.fallback_used ? ` ${t("metadata.fallbackUsed")}` : ""}</span>
)}
{r.candidates_count > 0 && (
<span>{r.candidates_count} {t("jobDetail.candidates", { plural: r.candidates_count > 1 ? "s" : "" })}</span>
)}
{r.best_confidence != null && (
<span>{Math.round(r.best_confidence * 100)}% {t("jobDetail.confidence")}</span>
)}
</div>
{r.best_candidate_json && (
<p className="text-xs text-muted-foreground mt-1">
{t("jobDetail.match", { title: (r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString() })}
</p>
)}
{r.error_message && (
<p className="text-xs text-destructive/80 mt-1">{r.error_message}</p>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* File errors */}
{errors.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>{t("jobDetail.fileErrors", { count: String(errors.length) })}</CardTitle>
<CardDescription>{t("jobDetail.fileErrorsDesc")}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
{errors.map((error) => (
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
<p className="text-sm text-destructive/80">{error.error_message}</p>
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString(locale)}</span>
</div>
))}
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,269 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "@/lib/api";
import { JobsList } from "@/app/components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ highlight?: string }> }) {
const { highlight } = await searchParams;
const { t } = await getServerTranslations();
const [jobs, libraries] = await Promise.all([
listJobs().catch(() => [] as IndexJobDto[]),
fetchLibraries().catch(() => [] as LibraryDto[])
]);
const libraryMap = new Map(libraries.map(l => [l.id, l.name]));
async function triggerRebuild(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await rebuildIndex(libraryId || undefined);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerFullRebuild(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await rebuildIndex(libraryId || undefined, true);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerRescan(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await rebuildIndex(libraryId || undefined, false, true);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerThumbnailsRebuild(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await rebuildThumbnails(libraryId || undefined);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerThumbnailsRegenerate(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await regenerateThumbnails(libraryId || undefined);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerMetadataBatch(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
if (libraryId) {
let result;
try {
result = await startMetadataBatch(libraryId);
} catch {
// Library may have metadata disabled — ignore silently
return;
}
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
} else {
// All libraries — skip those with metadata disabled
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
let lastId: string | undefined;
for (const lib of allLibraries) {
if (lib.metadata_provider === "none") continue;
try {
const result = await startMetadataBatch(lib.id);
if (result.status !== "already_running") lastId = result.id;
} catch {
// Library may have metadata disabled or other issue — skip
}
}
revalidatePath("/jobs");
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
}
}
async function triggerMetadataRefresh(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
if (libraryId) {
let result;
try {
result = await startMetadataRefresh(libraryId);
} catch {
return;
}
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
} else {
// All libraries — skip those with metadata disabled
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
let lastId: string | undefined;
for (const lib of allLibraries) {
if (lib.metadata_provider === "none") continue;
try {
const result = await startMetadataRefresh(lib.id);
if (result.status !== "already_running") lastId = result.id;
} catch {
// Library may have metadata disabled or no approved links — skip
}
}
revalidatePath("/jobs");
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
}
}
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{t("jobs.title")}
</h1>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("jobs.startJob")}</CardTitle>
<CardDescription>{t("jobs.startJobDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form>
<div className="mb-6">
<FormField className="max-w-xs">
<FormSelect name="library_id" defaultValue="">
<option value="">{t("jobs.allLibraries")}</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>{lib.name}</option>
))}
</FormSelect>
</FormField>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Indexation group */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{t("jobs.groupIndexation")}
</div>
<div className="space-y-2">
<button type="submit" formAction={triggerRebuild}
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span className="font-medium text-sm text-foreground">{t("jobs.rebuild")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rebuildShort")}</p>
</button>
<button type="submit" formAction={triggerRescan}
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary shrink-0" 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 className="font-medium text-sm text-foreground">{t("jobs.rescan")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rescanShort")}</p>
</button>
<button type="submit" formAction={triggerFullRebuild}
className="w-full text-left rounded-lg border border-destructive/30 bg-destructive/5 p-3 hover:bg-destructive/10 transition-colors group cursor-pointer">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-destructive shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="font-medium text-sm text-destructive">{t("jobs.fullRebuild")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.fullRebuildShort")}</p>
</button>
</div>
</div>
{/* Thumbnails group */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{t("jobs.groupThumbnails")}
</div>
<div className="space-y-2">
<button type="submit" formAction={triggerThumbnailsRebuild}
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="font-medium text-sm text-foreground">{t("jobs.generateThumbnails")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.generateThumbnailsShort")}</p>
</button>
<button type="submit" formAction={triggerThumbnailsRegenerate}
className="w-full text-left rounded-lg border border-warning/30 bg-warning/5 p-3 hover:bg-warning/10 transition-colors group cursor-pointer">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-warning shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="font-medium text-sm text-warning">{t("jobs.regenerateThumbnails")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.regenerateThumbnailsShort")}</p>
</button>
</div>
</div>
{/* Metadata group */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{t("jobs.groupMetadata")}
</div>
<div className="space-y-2">
<button type="submit" formAction={triggerMetadataBatch}
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-background">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary shrink-0" 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 className="font-medium text-sm text-foreground">{t("jobs.batchMetadata")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.batchMetadataShort")}</p>
</button>
<button type="submit" formAction={triggerMetadataRefresh}
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-background">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span className="font-medium text-sm text-foreground">{t("jobs.refreshMetadata")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.refreshMetadataShort")}</p>
</button>
</div>
</div>
</div>
</form>
</CardContent>
</Card>
<JobsList
initialJobs={jobs}
libraries={libraryMap}
highlightJobId={highlight}
/>
</>
);
}

View File

@@ -0,0 +1,127 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { ThemeToggle } from "@/app/theme-toggle";
import { JobsIndicator } from "@/app/components/JobsIndicator";
import { NavIcon, Icon } from "@/app/components/ui";
import { LogoutButton } from "@/app/components/LogoutButton";
import { MobileNav } from "@/app/components/MobileNav";
import { UserSwitcher } from "@/app/components/UserSwitcher";
import { fetchUsers } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslationKey } from "@/lib/i18n/fr";
type NavItem = {
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
labelKey: TranslationKey;
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
};
const navItems: NavItem[] = [
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
{ href: "/books", labelKey: "nav.books", icon: "books" },
{ href: "/series", labelKey: "nav.series", icon: "series" },
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
];
export default async function AppLayout({ children }: { children: ReactNode }) {
const { t } = await getServerTranslations();
const cookieStore = await cookies();
const activeUserId = cookieStore.get("as_user_id")?.value || null;
const users = await fetchUsers().catch(() => []);
async function setActiveUserAction(formData: FormData) {
"use server";
const userId = formData.get("user_id") as string;
const store = await cookies();
if (userId) {
store.set("as_user_id", userId, { path: "/", httpOnly: false, sameSite: "lax" });
} else {
store.delete("as_user_id");
}
revalidatePath("/", "layout");
}
return (
<>
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
<span className="text-sm text-muted-foreground font-medium hidden xl:inline">
{t("common.backoffice")}
</span>
</div>
</Link>
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden xl:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
<UserSwitcher
users={users}
activeUserId={activeUserId}
setActiveUserAction={setActiveUserAction}
/>
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden xl:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />
</Link>
<ThemeToggle />
<LogoutButton />
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
</div>
</div>
</nav>
</header>
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children}
</main>
</>
);
}
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
return (
<Link
href={href}
title={title}
className="
flex items-center
px-2 lg:px-3 py-2
rounded-lg
text-sm font-medium
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
active:scale-[0.98]
"
>
{children}
</Link>
);
}

View File

@@ -1,8 +1,9 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../components/BookCard";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
import { OffsetPagination } from "../../../components/ui";
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
import { OffsetPagination } from "@/app/components/ui";
import { notFound } from "next/navigation";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
@@ -14,6 +15,7 @@ export default async function LibraryBooksPage({
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { id } = await params;
const { t } = await getServerTranslations();
const searchParamsAwaited = await searchParams;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined;
@@ -38,14 +40,14 @@ export default async function LibraryBooksPage({
coverUrl: getBookCoverUrl(book.id)
}));
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
const seriesDisplayName = series === "unclassified" ? t("books.unclassified") : (series ?? "");
const totalPages = Math.ceil(booksPage.total / limit);
return (
<div className="space-y-6">
<LibrarySubPageHeader
library={library}
title={series ? `Books in "${seriesDisplayName}"` : "All Books"}
title={series ? t("libraryBooks.booksOfSeries", { series: seriesDisplayName }) : t("libraryBooks.allBooks")}
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -53,9 +55,9 @@ export default async function LibraryBooksPage({
}
iconColor="text-success"
filterInfo={series ? {
label: `Showing books from series "${seriesDisplayName}"`,
label: t("libraryBooks.filterLabel", { series: seriesDisplayName }),
clearHref: `/libraries/${id}/books`,
clearLabel: "View all books"
clearLabel: t("libraryBooks.viewAll")
} : undefined}
/>
@@ -71,7 +73,7 @@ export default async function LibraryBooksPage({
/>
</>
) : (
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
<EmptyState message={series ? t("libraryBooks.noBooksInSeries", { series: seriesDisplayName }) : t("libraryBooks.noBooks")} />
)}
</div>
);

View File

@@ -1,14 +1,24 @@
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
import { OffsetPagination } from "../../../../components/ui";
import { SafeHtml } from "../../../../components/SafeHtml";
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic";
import { OffsetPagination } from "@/app/components/ui";
import { SafeHtml } from "@/app/components/SafeHtml";
import Image from "next/image";
import Link from "next/link";
const EditSeriesForm = nextDynamic(
() => import("@/app/components/EditSeriesForm").then(m => m.EditSeriesForm)
);
const MetadataSearchModal = nextDynamic(
() => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal)
);
const ProwlarrSearchModal = nextDynamic(
() => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
);
import { notFound } from "next/navigation";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
@@ -20,6 +30,7 @@ export default async function SeriesDetailPage({
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { id, name } = await params;
const { t } = await getServerTranslations();
const searchParamsAwaited = await searchParams;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 50;
@@ -55,7 +66,7 @@ export default async function SeriesDetailPage({
const totalPages = Math.ceil(booksPage.total / limit);
const booksReadCount = booksPage.items.filter((b) => b.reading_status === "read").length;
const displayName = seriesName === "unclassified" ? "Non classifié" : seriesName;
const displayName = seriesName === "unclassified" ? t("books.unclassified") : seriesName;
// Use first book cover as series cover
const coverBookId = booksPage.items[0]?.id;
@@ -68,7 +79,7 @@ export default async function SeriesDetailPage({
href="/libraries"
className="text-muted-foreground hover:text-primary transition-colors"
>
Libraries
{t("nav.libraries")}
</Link>
<span className="text-muted-foreground">/</span>
<Link
@@ -88,10 +99,10 @@ export default async function SeriesDetailPage({
<div className="w-40 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
<Image
src={getBookCoverUrl(coverBookId)}
alt={`Cover of ${displayName}`}
alt={t("books.coverOf", { name: displayName })}
fill
className="object-cover"
unoptimized
sizes="160px"
/>
</div>
</div>
@@ -112,12 +123,7 @@ export default async function SeriesDetailPage({
seriesMeta.status === "cancelled" ? "bg-red-500/15 text-red-600" :
"bg-muted text-muted-foreground"
}`}>
{seriesMeta.status === "ongoing" ? "En cours" :
seriesMeta.status === "ended" ? "Terminée" :
seriesMeta.status === "hiatus" ? "Hiatus" :
seriesMeta.status === "cancelled" ? "Annulée" :
seriesMeta.status === "upcoming" ? "À paraître" :
seriesMeta.status}
{t(`seriesStatus.${seriesMeta.status}` as any) || seriesMeta.status}
</span>
)}
</div>
@@ -137,14 +143,14 @@ export default async function SeriesDetailPage({
)}
{((seriesMeta && seriesMeta.publishers.length > 0) || seriesMeta?.start_year) && <span className="w-px h-4 bg-border" />}
<span className="text-muted-foreground">
<span className="font-semibold text-foreground">{booksPage.total}</span> livre{booksPage.total !== 1 ? "s" : ""}
<span className="font-semibold text-foreground">{booksPage.total}</span> {t("dashboard.books").toLowerCase()}
</span>
<span className="w-px h-4 bg-border" />
<span className="text-muted-foreground">
<span className="font-semibold text-foreground">{booksReadCount}</span>/{booksPage.total} lu{booksPage.total !== 1 ? "s" : ""}
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
</span>
{/* Progress bar */}
{/* Reading progress bar */}
<div className="flex items-center gap-2 flex-1 min-w-[120px] max-w-[200px]">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
@@ -153,6 +159,22 @@ export default async function SeriesDetailPage({
/>
</div>
</div>
{/* Collection progress bar (owned / expected) */}
{missingData && missingData.total_external > 0 && (
<>
<span className="w-px h-4 bg-border" />
<span className="text-muted-foreground">
{booksPage.total}/{missingData.total_external} {t("series.missingCount", { count: missingData.missing_count, plural: missingData.missing_count !== 1 ? "s" : "" })}
</span>
<div className="w-[150px] h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all"
style={{ width: `${Math.round((booksPage.total / missingData.total_external) * 100)}%` }}
/>
</div>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
@@ -174,6 +196,10 @@ export default async function SeriesDetailPage({
currentStatus={seriesMeta?.status ?? null}
currentLockedFields={seriesMeta?.locked_fields ?? {}}
/>
<ProwlarrSearchModal
seriesName={seriesName}
missingBooks={missingData?.missing_books ?? null}
/>
<MetadataSearchModal
libraryId={id}
seriesName={seriesName}
@@ -196,7 +222,7 @@ export default async function SeriesDetailPage({
/>
</>
) : (
<EmptyState message="Aucun livre dans cette série" />
<EmptyState message={t("librarySeries.noBooksInSeries")} />
)}
</div>
);

View File

@@ -0,0 +1,144 @@
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "@/lib/api";
import { OffsetPagination } from "@/app/components/ui";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { SeriesFilters } from "@/app/components/SeriesFilters";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
export default async function LibrarySeriesPage({
params,
searchParams
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { id } = await params;
const { t } = await getServerTranslations();
const searchParamsAwaited = await searchParams;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
const hasMissing = searchParamsAwaited.has_missing === "true";
const [library, seriesPage, dbStatuses] = await Promise.all([
fetchLibraries().then(libs => libs.find(l => l.id === id)),
fetchSeries(id, page, limit, seriesStatus, hasMissing).catch(() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto),
fetchSeriesStatuses().catch(() => [] as string[]),
]);
if (!library) {
notFound();
}
const series = seriesPage.items;
const totalPages = Math.ceil(seriesPage.total / limit);
const KNOWN_STATUSES: Record<string, string> = {
ongoing: t("seriesStatus.ongoing"),
ended: t("seriesStatus.ended"),
hiatus: t("seriesStatus.hiatus"),
cancelled: t("seriesStatus.cancelled"),
upcoming: t("seriesStatus.upcoming"),
};
const seriesStatusOptions = [
{ value: "", label: t("seriesStatus.allStatuses") },
...dbStatuses.map((s) => ({ value: s, label: KNOWN_STATUSES[s] || s })),
];
return (
<div className="space-y-6">
<LibrarySubPageHeader
library={library}
title={t("series.title")}
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
}
iconColor="text-primary"
/>
<SeriesFilters
basePath={`/libraries/${id}/series`}
currentSeriesStatus={seriesStatus}
currentHasMissing={hasMissing}
seriesStatusOptions={seriesStatusOptions}
/>
{series.length > 0 ? (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{series.map((s) => (
<Link
key={s.name}
href={`/libraries/${id}/series/${encodeURIComponent(s.name)}`}
className="group"
>
<div className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200 ${s.books_read_count >= s.book_count ? "opacity-50" : ""}`}>
<div className="aspect-[2/3] relative bg-muted/50">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={t("books.coverOf", { name: s.name })}
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
/>
</div>
<div className="p-3">
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name === "unclassified" ? t("books.unclassified") : s.name}
</h3>
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-muted-foreground">
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
</p>
<MarkSeriesReadButton
seriesName={s.name}
bookCount={s.book_count}
booksReadCount={s.books_read_count}
/>
</div>
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
{s.series_status && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
s.series_status === "ended" ? "bg-green-500/15 text-green-600" :
s.series_status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
s.series_status === "cancelled" ? "bg-red-500/15 text-red-600" :
"bg-muted text-muted-foreground"
}`}>
{KNOWN_STATUSES[s.series_status] || s.series_status}
</span>
)}
{s.missing_count != null && s.missing_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-yellow-500/15 text-yellow-600">
{t("series.missingCount", { count: String(s.missing_count) })}
</span>
)}
</div>
</div>
</div>
</Link>
))}
</div>
<OffsetPagination
currentPage={page}
totalPages={totalPages}
pageSize={limit}
totalItems={seriesPage.total}
/>
</>
) : (
<div className="text-center py-12 text-muted-foreground">
<p>{t("librarySeries.noSeries")}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { revalidatePath } from "next/cache";
import Image from "next/image";
import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "@/lib/api";
import type { TranslationKey } from "@/lib/i18n/fr";
import { getServerTranslations } from "@/lib/i18n/server";
import { LibraryActions } from "@/app/components/LibraryActions";
import { LibraryForm } from "@/app/components/LibraryForm";
import { ProviderIcon } from "@/app/components/ProviderIcon";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge
} from "@/app/components/ui";
export const dynamic = "force-dynamic";
function formatNextScan(nextScanAt: string | null, imminentLabel: string): string {
if (!nextScanAt) return "-";
const date = new Date(nextScanAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return imminentLabel;
if (diff < 60000) return "< 1 min";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
return `${Math.floor(diff / 86400000)}d`;
}
export default async function LibrariesPage() {
const { t } = await getServerTranslations();
const [libraries, folders] = await Promise.all([
fetchLibraries().catch(() => [] as LibraryDto[]),
listFolders().catch(() => [] as FolderItem[])
]);
const thumbnailMap = new Map(
libraries.map(lib => [
lib.id,
(lib.thumbnail_book_ids || []).map(bookId => getBookCoverUrl(bookId)),
])
);
async function addLibrary(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const rootPath = formData.get("root_path") as string;
if (name && rootPath) {
await createLibrary(name, rootPath);
revalidatePath("/libraries");
}
}
async function removeLibrary(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteLibrary(id);
revalidatePath("/libraries");
}
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{t("libraries.title")}
</h1>
</div>
{/* Add Library Form */}
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("libraries.addLibrary")}</CardTitle>
<CardDescription>{t("libraries.addLibraryDescription")}</CardDescription>
</CardHeader>
<CardContent>
<LibraryForm initialFolders={folders} action={addLibrary} />
</CardContent>
</Card>
{/* Libraries Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{libraries.map((lib) => {
const thumbnails = thumbnailMap.get(lib.id) || [];
return (
<Card key={lib.id} className="flex flex-col overflow-hidden">
{/* Thumbnail fan */}
{thumbnails.length > 0 ? (
<Link href={`/libraries/${lib.id}/series`} className="block relative h-48 overflow-hidden bg-muted/10">
<Image
src={thumbnails[0]}
alt=""
fill
className="object-cover blur-xl scale-110 opacity-40"
sizes="(max-width: 768px) 100vw, 33vw"
loading="lazy"
/>
<div className="absolute inset-0 flex items-end justify-center">
{thumbnails.map((url, i) => {
const count = thumbnails.length;
const mid = (count - 1) / 2;
const angle = (i - mid) * 12;
const radius = 220;
const rad = ((angle - 90) * Math.PI) / 180;
const cx = Math.cos(rad) * radius;
const cy = Math.sin(rad) * radius;
return (
<Image
key={i}
src={url}
alt=""
width={96}
height={144}
className="absolute object-cover shadow-lg"
style={{
transform: `translate(${cx}px, ${cy}px) rotate(${angle}deg)`,
transformOrigin: 'bottom center',
zIndex: count - Math.abs(Math.round(i - mid)),
bottom: '-185px',
}}
sizes="96px"
loading="lazy"
/>
);
})}
</div>
</Link>
) : (
<div className="h-8 bg-muted/10" />
)}
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{lib.name}</CardTitle>
{!lib.enabled && <Badge variant="muted" className="mt-1">{t("libraries.disabled")}</Badge>}
</div>
<div className="flex items-center gap-1">
<LibraryActions
libraryId={lib.id}
monitorEnabled={lib.monitor_enabled}
scanMode={lib.scan_mode}
watcherEnabled={lib.watcher_enabled}
metadataProvider={lib.metadata_provider}
fallbackMetadataProvider={lib.fallback_metadata_provider}
metadataRefreshMode={lib.metadata_refresh_mode}
/>
<form>
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="ghost" size="sm" formAction={removeLibrary} className="text-muted-foreground hover:text-destructive">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</form>
</div>
</div>
<code className="text-xs font-mono text-muted-foreground break-all">{lib.root_path}</code>
</CardHeader>
<CardContent className="flex-1 pt-0">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 mb-3">
<Link
href={`/libraries/${lib.id}/books`}
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
>
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
<span className="text-xs text-muted-foreground">{t("libraries.books")}</span>
</Link>
<Link
href={`/libraries/${lib.id}/series`}
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
>
<span className="block text-2xl font-bold text-foreground">{lib.series_count}</span>
<span className="text-xs text-muted-foreground">{t("libraries.series")}</span>
</Link>
</div>
{/* Configuration tags */}
<div className="flex flex-wrap gap-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
lib.monitor_enabled
? 'bg-success/10 text-success'
: 'bg-muted/50 text-muted-foreground'
}`}>
<span className="text-[9px]">{lib.monitor_enabled ? '●' : '○'}</span>
{t("libraries.scanLabel", { mode: t(`monitoring.${lib.scan_mode}` as TranslationKey) })}
</span>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
lib.watcher_enabled
? 'bg-warning/10 text-warning'
: 'bg-muted/50 text-muted-foreground'
}`}>
<span>{lib.watcher_enabled ? '⚡' : '○'}</span>
<span>{t("libraries.watcherLabel")}</span>
</span>
{lib.metadata_provider && lib.metadata_provider !== "none" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-primary/10 text-primary">
<ProviderIcon provider={lib.metadata_provider} size={11} />
{lib.metadata_provider.replace('_', ' ')}
</span>
)}
{lib.metadata_refresh_mode !== "manual" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-muted/50 text-muted-foreground">
{t("libraries.metaRefreshLabel", { mode: t(`monitoring.${lib.metadata_refresh_mode}` as TranslationKey) })}
</span>
)}
{lib.monitor_enabled && lib.next_scan_at && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-muted/50 text-muted-foreground">
{t("libraries.nextScan", { time: formatNextScan(lib.next_scan_at, t("libraries.imminent")) })}
</span>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,514 @@
import React from "react";
import { fetchStats, fetchUsers, StatsResponse, UserDto } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
import { PeriodToggle } from "@/app/components/PeriodToggle";
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
import Link from "next/link";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
export const dynamic = "force-dynamic";
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
function formatNumber(n: number, locale: string): string {
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
}
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
const loc = locale === "fr" ? "fr-FR" : "en-US";
if (period === "month") {
// raw = "YYYY-MM"
const [y, m] = raw.split("-");
const d = new Date(Number(y), Number(m) - 1, 1);
return d.toLocaleDateString(loc, { month: "short" });
}
if (period === "week") {
// raw = "YYYY-MM-DD" (Monday of the week)
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
}
// day: raw = "YYYY-MM-DD"
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
}
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium text-foreground truncate">{label}</span>
<span className="text-muted-foreground shrink-0 ml-2">{subLabel || value}</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: color }}
/>
</div>
</div>
);
}
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParamsAwaited = await searchParams;
const rawPeriod = searchParamsAwaited.period;
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
const { t, locale } = await getServerTranslations();
let stats: StatsResponse | null = null;
let users: UserDto[] = [];
try {
[stats, users] = await Promise.all([
fetchStats(period),
fetchUsers().catch(() => []),
]);
} catch (e) {
console.error("Failed to fetch stats:", e);
}
if (!stats) {
return (
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">StripStream Backoffice</h1>
<p className="text-lg text-muted-foreground">{t("dashboard.loadError")}</p>
</div>
<QuickLinks t={t} />
</div>
);
}
const {
overview,
reading_status,
currently_reading = [],
recently_read = [],
reading_over_time = [],
users_reading_over_time = [],
by_format,
by_library,
top_series,
additions_over_time,
jobs_over_time = [],
metadata = { total_series: 0, series_linked: 0, series_unlinked: 0, books_with_summary: 0, books_with_isbn: 0, by_provider: [] },
} = stats;
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
const formatColors = [
"hsl(198 78% 37%)", "hsl(142 60% 45%)", "hsl(45 93% 47%)",
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
"hsl(170 60% 45%)", "hsl(220 60% 50%)",
];
const noDataLabel = t("common.noData");
return (
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="mb-2">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{t("dashboard.title")}
</h1>
<p className="text-muted-foreground mt-2 max-w-2xl">
{t("dashboard.subtitle")}
</p>
</div>
{/* Overview stat cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard icon="book" label={t("dashboard.books")} value={formatNumber(overview.total_books, locale)} color="success" />
<StatCard icon="series" label={t("dashboard.series")} value={formatNumber(overview.total_series, locale)} color="primary" />
<StatCard icon="library" label={t("dashboard.libraries")} value={formatNumber(overview.total_libraries, locale)} color="warning" />
<StatCard icon="pages" label={t("dashboard.pages")} value={formatNumber(overview.total_pages, locale)} color="primary" />
<StatCard icon="author" label={t("dashboard.authors")} value={formatNumber(overview.total_authors, locale)} color="success" />
<StatCard icon="size" label={t("dashboard.totalSize")} value={formatBytes(overview.total_size_bytes)} color="warning" />
</div>
{/* Currently reading + Recently read */}
{(currently_reading.length > 0 || recently_read.length > 0) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Currently reading */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
</CardHeader>
<CardContent>
<CurrentlyReadingList
items={currently_reading}
allLabel={t("dashboard.allUsers")}
emptyLabel={t("dashboard.noCurrentlyReading")}
pageProgressTemplate={t("dashboard.pageProgress")}
/>
</CardContent>
</Card>
{/* Recently read */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
</CardHeader>
<CardContent>
<RecentlyReadList
items={recently_read}
allLabel={t("dashboard.allUsers")}
emptyLabel={t("dashboard.noRecentlyRead")}
/>
</CardContent>
</Card>
</div>
)}
{/* Reading activity line chart */}
<Card hover={false}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
{(() => {
const userColors = [
"hsl(142 60% 45%)", "hsl(198 78% 37%)", "hsl(45 93% 47%)",
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
];
const usernames = [...new Set(users_reading_over_time.map(r => r.username))];
if (usernames.length === 0) {
return (
<RcAreaChart
noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
color="hsl(142 60% 45%)"
/>
);
}
// Pivot: { label, username1: n, username2: n, ... }
const byMonth = new Map<string, Record<string, unknown>>();
for (const row of users_reading_over_time) {
const label = formatChartLabel(row.month, period, locale);
if (!byMonth.has(row.month)) byMonth.set(row.month, { label });
byMonth.get(row.month)![row.username] = row.books_read;
}
const chartData = [...byMonth.values()];
const lines = usernames.map((u, i) => ({
key: u,
label: u,
color: userColors[i % userColors.length],
}));
return <RcMultiLineChart data={chartData} lines={lines} noDataLabel={noDataLabel} />;
})()}
</CardContent>
</Card>
{/* Charts row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Reading status par lecteur */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<RcDonutChart
noDataLabel={noDataLabel}
data={[
{ name: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
{ name: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
]}
/>
) : (
<div className="space-y-3">
{users.map((user) => {
const total = overview.total_books;
const read = user.books_read;
const reading = user.books_reading;
const unread = Math.max(0, total - read - reading);
const readPct = total > 0 ? (read / total) * 100 : 0;
const readingPct = total > 0 ? (reading / total) * 100 : 0;
return (
<div key={user.id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-foreground truncate">{user.username}</span>
<span className="text-xs text-muted-foreground shrink-0 ml-2">
<span className="text-success font-medium">{read}</span>
{reading > 0 && <span className="text-amber-500 font-medium"> · {reading}</span>}
<span className="text-muted-foreground/60"> / {total}</span>
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
<div className="h-full bg-success transition-all duration-500" style={{ width: `${readPct}%` }} />
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${readingPct}%` }} />
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* By format donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.byFormat")}</CardTitle>
</CardHeader>
<CardContent>
<RcDonutChart
noDataLabel={noDataLabel}
data={by_format.slice(0, 6).map((f, i) => ({
name: (f.format || t("dashboard.unknown")).toUpperCase(),
value: f.count,
color: formatColors[i % formatColors.length],
}))}
/>
</CardContent>
</Card>
{/* By library donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.byLibrary")}</CardTitle>
</CardHeader>
<CardContent>
<RcDonutChart
noDataLabel={noDataLabel}
data={by_library.slice(0, 6).map((l, i) => ({
name: l.library_name,
value: l.book_count,
color: formatColors[i % formatColors.length],
}))}
/>
</CardContent>
</Card>
</div>
{/* Metadata row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Series metadata coverage donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.metadataCoverage")}</CardTitle>
</CardHeader>
<CardContent>
<RcDonutChart
noDataLabel={noDataLabel}
data={[
{ name: t("dashboard.seriesLinked"), value: metadata.series_linked, color: "hsl(142 60% 45%)" },
{ name: t("dashboard.seriesUnlinked"), value: metadata.series_unlinked, color: "hsl(220 13% 70%)" },
]}
/>
</CardContent>
</Card>
{/* By provider donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.byProvider")}</CardTitle>
</CardHeader>
<CardContent>
<RcDonutChart
noDataLabel={noDataLabel}
data={metadata.by_provider.map((p, i) => ({
name: p.provider.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
value: p.count,
color: formatColors[i % formatColors.length],
}))}
/>
</CardContent>
</Card>
{/* Book metadata quality */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.bookMetadata")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<HorizontalBar
label={t("dashboard.withSummary")}
value={metadata.books_with_summary}
max={overview.total_books}
subLabel={overview.total_books > 0 ? `${Math.round((metadata.books_with_summary / overview.total_books) * 100)}%` : "0%"}
color="hsl(198 78% 37%)"
/>
<HorizontalBar
label={t("dashboard.withIsbn")}
value={metadata.books_with_isbn}
max={overview.total_books}
subLabel={overview.total_books > 0 ? `${Math.round((metadata.books_with_isbn / overview.total_books) * 100)}%` : "0%"}
color="hsl(280 60% 50%)"
/>
</div>
</CardContent>
</Card>
</div>
{/* Libraries breakdown + Top series */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{by_library.length > 0 && (
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.libraries")}</CardTitle>
</CardHeader>
<CardContent>
<RcStackedBar
data={by_library.map((lib) => ({
name: lib.library_name,
read: lib.read_count,
reading: lib.reading_count,
unread: lib.unread_count,
sizeLabel: formatBytes(lib.size_bytes),
}))}
labels={{
read: t("status.read"),
reading: t("status.reading"),
unread: t("status.unread"),
books: t("dashboard.books"),
}}
/>
</CardContent>
</Card>
)}
{/* Top series */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.popularSeries")}</CardTitle>
</CardHeader>
<CardContent>
<RcHorizontalBar
noDataLabel={t("dashboard.noSeries")}
data={top_series.slice(0, 8).map((s) => ({
name: s.series,
value: s.book_count,
subLabel: t("dashboard.readCount", { read: s.read_count, total: s.book_count }),
}))}
color="hsl(142 60% 45%)"
/>
</CardContent>
</Card>
</div>
{/* Additions line chart full width */}
<Card hover={false}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcAreaChart
noDataLabel={noDataLabel}
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
color="hsl(198 78% 37%)"
/>
</CardContent>
</Card>
{/* Jobs over time multi-line chart */}
<Card hover={false}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcMultiLineChart
noDataLabel={noDataLabel}
data={jobs_over_time.map((j) => ({
label: formatChartLabel(j.label, period, locale),
scan: j.scan,
rebuild: j.rebuild,
thumbnail: j.thumbnail,
other: j.other,
}))}
lines={[
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
]}
/>
</CardContent>
</Card>
{/* Quick links */}
<QuickLinks t={t} />
</div>
);
}
function StatCard({ icon, label, value, color }: { icon: string; label: string; value: string; color: string }) {
const icons: Record<string, React.ReactNode> = {
book: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />,
series: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />,
library: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />,
pages: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />,
author: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />,
size: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />,
};
const colorClasses: Record<string, string> = {
primary: "bg-primary/10 text-primary",
success: "bg-success/10 text-success",
warning: "bg-warning/10 text-warning",
};
return (
<Card hover={false} className="p-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${colorClasses[color]}`}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{icons[icon]}
</svg>
</div>
<div className="min-w-0">
<p className="text-xl font-bold text-foreground leading-tight">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
</Card>
);
}
function QuickLinks({ t }: { t: TranslateFunction }) {
const links = [
{ href: "/libraries", label: t("nav.libraries"), bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
{ href: "/books", label: t("nav.books"), bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> },
{ href: "/series", label: t("nav.series"), bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
{ href: "/jobs", label: t("nav.jobs"), bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{links.map((l) => (
<Link
key={l.href}
href={l.href as any}
className="group p-4 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 flex items-center gap-3"
>
<div className={`w-9 h-9 rounded-lg flex items-center justify-center transition-colors duration-200 ${l.bg} ${l.hoverBg}`}>
<svg className={`w-5 h-5 ${l.text} ${l.hoverText}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{l.icon}
</svg>
</div>
<span className="font-medium text-foreground text-sm">{l.label}</span>
</Link>
))}
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";
import { ProviderIcon } from "@/app/components/ProviderIcon";
export const dynamic = "force-dynamic";
export default async function SeriesPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { t } = await getServerTranslations();
const searchParamsAwaited = await searchParams;
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
const hasMissing = searchParamsAwaited.has_missing === "true";
const metadataProvider = typeof searchParamsAwaited.metadata_provider === "string" ? searchParamsAwaited.metadata_provider : undefined;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
const [libraries, seriesPage, dbStatuses] = await Promise.all([
fetchLibraries().catch(() => [] as LibraryDto[]),
fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit, sort, seriesStatus, hasMissing, metadataProvider).catch(
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
),
fetchSeriesStatuses().catch(() => [] as string[]),
]);
const series = seriesPage.items;
const totalPages = Math.ceil(seriesPage.total / limit);
const sortOptions = [
{ value: "", label: t("books.sortTitle") },
{ value: "latest", label: t("books.sortLatest") },
];
const hasFilters = searchQuery || libraryId || readingStatus || sort || seriesStatus || hasMissing || metadataProvider;
const libraryOptions = [
{ value: "", label: t("books.allLibraries") },
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
];
const statusOptions = [
{ value: "", label: t("common.all") },
{ value: "unread", label: t("status.unread") },
{ value: "reading", label: t("status.reading") },
{ value: "read", label: t("status.read") },
];
const KNOWN_STATUSES: Record<string, string> = {
ongoing: t("seriesStatus.ongoing"),
ended: t("seriesStatus.ended"),
hiatus: t("seriesStatus.hiatus"),
cancelled: t("seriesStatus.cancelled"),
upcoming: t("seriesStatus.upcoming"),
};
const seriesStatusOptions = [
{ value: "", label: t("seriesStatus.allStatuses") },
...dbStatuses.map((s) => ({ value: s, label: KNOWN_STATUSES[s] || s })),
];
const missingOptions = [
{ value: "", label: t("common.all") },
{ value: "true", label: t("series.missingBooks") },
];
const metadataOptions = [
{ value: "", label: t("series.metadataAll") },
{ value: "linked", label: t("series.metadataLinked") },
{ value: "unlinked", label: t("series.metadataUnlinked") },
{ value: "google_books", label: "Google Books" },
{ value: "open_library", label: "Open Library" },
{ value: "comicvine", label: "ComicVine" },
{ value: "anilist", label: "AniList" },
{ value: "bedetheque", label: "Bédéthèque" },
];
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{t("series.title")}
</h1>
</div>
<Card className="mb-6">
<CardContent className="pt-6">
<LiveSearchForm
basePath="/series"
fields={[
{ name: "q", type: "text", label: t("common.search"), placeholder: t("series.searchPlaceholder") },
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
{ name: "status", type: "select", label: t("series.reading"), options: statusOptions },
{ name: "series_status", type: "select", label: t("editSeries.status"), options: seriesStatusOptions },
{ name: "has_missing", type: "select", label: t("series.missing"), options: missingOptions },
{ name: "metadata_provider", type: "select", label: t("series.metadata"), options: metadataOptions },
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
]}
/>
</CardContent>
</Card>
{/* Results count */}
<p className="text-sm text-muted-foreground mb-4">
{seriesPage.total} {t("series.title").toLowerCase()}
{searchQuery && <> {t("series.matchingQuery")} &quot;{searchQuery}&quot;</>}
</p>
{/* Series Grid */}
{series.length > 0 ? (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{series.map((s) => (
<Link
key={s.name}
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
className="group"
>
<div
className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 ${
s.books_read_count >= s.book_count ? "opacity-50" : ""
}`}
>
<div className="aspect-[2/3] relative bg-muted/50">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={t("books.coverOf", { name: s.name })}
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
/>
</div>
<div className="p-3">
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name === "unclassified" ? t("books.unclassified") : s.name}
</h3>
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-muted-foreground">
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
</p>
<MarkSeriesReadButton
seriesName={s.name}
bookCount={s.book_count}
booksReadCount={s.books_read_count}
/>
</div>
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
{s.series_status && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
s.series_status === "ended" ? "bg-green-500/15 text-green-600" :
s.series_status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
s.series_status === "cancelled" ? "bg-red-500/15 text-red-600" :
"bg-muted text-muted-foreground"
}`}>
{KNOWN_STATUSES[s.series_status] || s.series_status}
</span>
)}
{s.missing_count != null && s.missing_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-yellow-500/15 text-yellow-600">
{t("series.missingCount", { count: String(s.missing_count), plural: s.missing_count > 1 ? "s" : "" })}
</span>
)}
{s.metadata_provider && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-purple-500/15 text-purple-600 inline-flex items-center gap-0.5">
<ProviderIcon provider={s.metadata_provider} size={10} />
</span>
)}
</div>
</div>
</div>
</Link>
))}
</div>
<OffsetPagination
currentPage={page}
totalPages={totalPages}
pageSize={limit}
totalItems={seriesPage.total}
/>
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-muted-foreground text-lg">
{hasFilters ? t("series.noResults") : t("series.noSeries")}
</p>
</div>
)}
</>
);
}

View File

@@ -1,4 +1,4 @@
import { getSettings, getCacheStats, getThumbnailStats } from "../../lib/api";
import { getSettings, getCacheStats, getThumbnailStats, fetchUsers } from "@/lib/api";
import SettingsPage from "./SettingsPage";
export const dynamic = "force-dynamic";
@@ -23,5 +23,7 @@ export default async function SettingsPageWrapper() {
directory: "/data/thumbnails"
}));
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />;
const users = await fetchUsers().catch(() => []);
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} />;
}

View File

@@ -0,0 +1,316 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listTokens, createToken, revokeToken, deleteToken, updateToken, fetchUsers, createUser, deleteUser, updateUser, TokenDto, UserDto } from "@/lib/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
import { TokenUserSelect } from "@/app/components/TokenUserSelect";
import { UsernameEdit } from "@/app/components/UsernameEdit";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
export default async function TokensPage({
searchParams
}: {
searchParams: Promise<{ created?: string }>;
}) {
const { t } = await getServerTranslations();
const params = await searchParams;
const tokens = await listTokens().catch(() => [] as TokenDto[]);
const users = await fetchUsers().catch(() => [] as UserDto[]);
async function createTokenAction(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const scope = formData.get("scope") as string;
const userId = (formData.get("user_id") as string) || undefined;
if (name) {
const result = await createToken(name, scope, userId);
revalidatePath("/tokens");
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
}
}
async function revokeTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await revokeToken(id);
revalidatePath("/tokens");
}
async function deleteTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteToken(id);
revalidatePath("/tokens");
}
async function createUserAction(formData: FormData) {
"use server";
const username = formData.get("username") as string;
if (username) {
await createUser(username);
revalidatePath("/tokens");
}
}
async function deleteUserAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteUser(id);
revalidatePath("/tokens");
}
async function renameUserAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
const username = formData.get("username") as string;
if (username?.trim()) {
await updateUser(id, username.trim());
revalidatePath("/tokens");
}
}
async function reassignTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
const userId = (formData.get("user_id") as string) || null;
await updateToken(id, userId);
revalidatePath("/tokens");
}
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-destructive" 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>
{t("tokens.title")}
</h1>
</div>
{/* ── Lecteurs ─────────────────────────────────────────── */}
<div className="mb-2">
<h2 className="text-xl font-semibold text-foreground">{t("users.title")}</h2>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("users.createNew")}</CardTitle>
<CardDescription>{t("users.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createUserAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="username" placeholder={t("users.username")} required autoComplete="off" />
</FormField>
<Button type="submit">{t("users.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden mb-10">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.tokenCount")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.read")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.reading")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.createdAt")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{/* Ligne admin synthétique */}
<tr className="hover:bg-accent/50 transition-colors bg-destructive/5">
<td className="px-4 py-3 text-sm font-medium text-foreground flex items-center gap-2">
{process.env.ADMIN_USERNAME ?? "admin"}
<Badge variant="destructive">{t("tokens.scopeAdmin")}</Badge>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{tokens.filter(tok => tok.scope === "admin" && !tok.revoked_at).length}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
</tr>
{/* Ligne tokens read non assignés */}
{(() => {
const unassigned = tokens.filter(tok => tok.scope === "read" && !tok.user_id && !tok.revoked_at);
if (unassigned.length === 0) return null;
return (
<tr className="hover:bg-accent/50 transition-colors bg-warning/5">
<td className="px-4 py-3 text-sm font-medium text-muted-foreground italic">
{t("tokens.noUser")}
</td>
<td className="px-4 py-3 text-sm text-warning font-medium">{unassigned.length}</td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
</tr>
);
})()}
{users.map((user) => (
<tr key={user.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3">
<UsernameEdit userId={user.id} currentUsername={user.username} action={renameUserAction} />
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{user.token_count}</td>
<td className="px-4 py-3 text-sm">
{user.books_read > 0
? <span className="font-medium text-success">{user.books_read}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm">
{user.books_reading > 0
? <span className="font-medium text-amber-500">{user.books_reading}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<form action={deleteUserAction}>
<input type="hidden" name="id" value={user.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t("common.delete")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* ── Tokens API ───────────────────────────────────────── */}
<div className="mb-2">
<h2 className="text-xl font-semibold text-foreground">{t("tokens.apiTokens")}</h2>
</div>
{params.created ? (
<Card className="mb-6 border-success/50 bg-success/5">
<CardHeader>
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
</CardHeader>
<CardContent>
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
</CardContent>
</Card>
) : null}
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("tokens.createNew")}</CardTitle>
<CardDescription>{t("tokens.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createTokenAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder={t("tokens.tokenName")} required autoComplete="off" />
</FormField>
<FormField className="w-32">
<FormSelect name="scope" defaultValue="read">
<option value="read">{t("tokens.scopeRead")}</option>
<option value="admin">{t("tokens.scopeAdmin")}</option>
</FormSelect>
</FormField>
<FormField className="w-48">
<FormSelect name="user_id" defaultValue="">
<option value="">{t("tokens.noUser")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.username}</option>
))}
</FormSelect>
</FormField>
<Button type="submit">{t("tokens.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.user")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{tokens.map((token) => (
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
<td className="px-4 py-3 text-sm">
<TokenUserSelect
tokenId={token.id}
currentUserId={token.user_id}
users={users}
action={reassignTokenAction}
noUserLabel={t("tokens.noUser")}
/>
</td>
<td className="px-4 py-3 text-sm">
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
{token.scope}
</Badge>
</td>
<td className="px-4 py-3 text-sm">
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
</td>
<td className="px-4 py-3 text-sm">
{token.revoked_at ? (
<Badge variant="error">{t("tokens.revoked")}</Badge>
) : (
<Badge variant="success">{t("tokens.active")}</Badge>
)}
</td>
<td className="px-4 py-3">
{!token.revoked_at ? (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("tokens.revoke")}
</Button>
</form>
) : (
<form action={deleteTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t("common.delete")}
</Button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { createSessionToken, SESSION_COOKIE } from "@/lib/session";
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
if (!body || typeof body.username !== "string" || typeof body.password !== "string") {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const expectedUsername = process.env.ADMIN_USERNAME || "admin";
const expectedPassword = process.env.ADMIN_PASSWORD;
if (!expectedPassword) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
if (body.username !== expectedUsername || body.password !== expectedPassword) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await createSessionToken();
const response = NextResponse.json({ success: true });
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60,
path: "/",
});
return response;
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/session";
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.delete(SESSION_COOKIE);
return response;
}

View File

@@ -28,12 +28,9 @@ export async function GET(
});
}
// Récupérer le content-type et les données
const contentType = response.headers.get("content-type") || "image/webp";
const imageBuffer = await response.arrayBuffer();
// Retourner l'image avec le bon content-type
return new NextResponse(imageBuffer, {
return new NextResponse(response.body, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=300",

View File

@@ -9,10 +9,25 @@ export async function GET(
try {
const { baseUrl, token } = config();
const ifNoneMatch = request.headers.get("if-none-match");
const fetchHeaders: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
if (ifNoneMatch) {
fetchHeaders["If-None-Match"] = ifNoneMatch;
}
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
headers: { Authorization: `Bearer ${token}` },
headers: fetchHeaders,
next: { revalidate: 86400 },
});
// Forward 304 Not Modified as-is
if (response.status === 304) {
return new NextResponse(null, { status: 304 });
}
if (!response.ok) {
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
status: response.status
@@ -20,14 +35,17 @@ export async function GET(
}
const contentType = response.headers.get("content-type") || "image/webp";
const imageBuffer = await response.arrayBuffer();
const etag = response.headers.get("etag");
return new NextResponse(imageBuffer, {
headers: {
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
};
if (etag) {
headers["ETag"] = etag;
}
return new NextResponse(response.body, { headers });
} catch (error) {
console.error("Error fetching thumbnail:", error);
return new NextResponse("Failed to fetch thumbnail", { status: 500 });

View File

@@ -11,6 +11,7 @@ export async function GET(request: NextRequest) {
let lastData: string | null = null;
let isActive = true;
let consecutiveErrors = 0;
let intervalId: ReturnType<typeof setInterval> | null = null;
const fetchJobs = async () => {
if (!isActive) return;
@@ -25,23 +26,28 @@ export async function GET(request: NextRequest) {
const data = await response.json();
const dataStr = JSON.stringify(data);
// Send if data changed
// Send only if data changed
if (dataStr !== lastData && isActive) {
lastData = dataStr;
try {
controller.enqueue(
new TextEncoder().encode(`data: ${dataStr}\n\n`)
);
} catch (err) {
// Controller closed, ignore
} catch {
isActive = false;
}
}
// Adapt interval: 2s when active jobs exist, 15s when idle
const hasActiveJobs = data.some((j: { status: string }) =>
j.status === "running" || j.status === "pending" || j.status === "extracting_pages" || j.status === "generating_thumbnails"
);
const nextInterval = hasActiveJobs ? 2000 : 15000;
restartInterval(nextInterval);
}
} catch (error) {
if (isActive) {
consecutiveErrors++;
// Only log first failure and every 30th to avoid spam
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
}
@@ -49,22 +55,18 @@ export async function GET(request: NextRequest) {
}
};
// Initial fetch
await fetchJobs();
const restartInterval = (ms: number) => {
if (intervalId !== null) clearInterval(intervalId);
intervalId = setInterval(fetchJobs, ms);
};
// Poll every 2 seconds
const interval = setInterval(async () => {
if (!isActive) {
clearInterval(interval);
return;
}
// Initial fetch + start polling
await fetchJobs();
}, 2000);
// Cleanup
request.signal.addEventListener("abort", () => {
isActive = false;
clearInterval(interval);
if (intervalId !== null) clearInterval(intervalId);
controller.close();
});
},

View File

@@ -7,8 +7,8 @@ export async function PATCH(
) {
const { id } = await params;
try {
const { monitor_enabled, scan_mode, watcher_enabled } = await request.json();
const data = await updateLibraryMonitoring(id, monitor_enabled, scan_mode, watcher_enabled);
const { monitor_enabled, scan_mode, watcher_enabled, metadata_refresh_mode } = await request.json();
const data = await updateLibraryMonitoring(id, monitor_enabled, scan_mode, watcher_enabled, metadata_refresh_mode);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update monitoring settings";

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, MetadataBatchReportDto } from "@/lib/api";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
const data = await apiFetch<MetadataBatchReportDto>(`/metadata/batch/${id}/report`);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fetch report";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, MetadataBatchResultDto } from "@/lib/api";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
const status = searchParams.get("status") || "";
const params = status ? `?status=${status}` : "";
const data = await apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${id}/results${params}`);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fetch results";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch<{ id: string; status: string }>("/metadata/batch", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to start batch";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET(request: NextRequest) {
try {
const jobId = request.nextUrl.searchParams.get("job_id");
if (!jobId) {
return NextResponse.json({ error: "job_id required" }, { status: 400 });
}
const data = await apiFetch(`/metadata/refresh/${jobId}/report`);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to get report";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch<{ id: string; status: string }>("/metadata/refresh", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to start refresh";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse, NextRequest } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch("/prowlarr/search", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to search Prowlarr";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch("/prowlarr/test");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to test Prowlarr connection";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse, NextRequest } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch("/qbittorrent/add", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to add torrent";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch("/qbittorrent/test");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to test qBittorrent";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch<string[]>("/series/provider-statuses");
return NextResponse.json(data);
} catch {
return NextResponse.json([], { status: 200 });
}
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch<string[]>("/series/statuses");
return NextResponse.json(data);
} catch {
return NextResponse.json([], { status: 200 });
}
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const data = await apiFetch<unknown>(`/settings/status-mappings/${id}`, {
method: "DELETE",
});
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: "Failed to delete status mapping" }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch<unknown>("/settings/status-mappings");
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: "Failed to fetch status mappings" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch<unknown>("/settings/status-mappings", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: "Failed to save status mapping" }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch("/telegram/test");
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to test Telegram connection";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -1,14 +1,15 @@
"use client";
import { useState } from "react";
import { memo, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { BookDto, ReadingStatus } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context";
const readingStatusOverlay: Record<ReadingStatus, { label: string; className: string } | null> = {
const readingStatusOverlayClasses: Record<ReadingStatus, string | null> = {
unread: null,
reading: { label: "En cours", className: "bg-amber-500/90 text-white" },
read: { label: "Lu", className: "bg-green-600/90 text-white" },
reading: "bg-amber-500/90 text-white",
read: "bg-green-600/90 text-white",
};
interface BookCardProps {
@@ -16,7 +17,7 @@ interface BookCardProps {
readingStatus?: ReadingStatus;
}
function BookImage({ src, alt }: { src: string; alt: string }) {
const BookImage = memo(function BookImage({ src, alt }: { src: string; alt: string }) {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
@@ -50,16 +51,21 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
unoptimized
/>
</div>
);
}
});
export function BookCard({ book, readingStatus }: BookCardProps) {
export const BookCard = memo(function BookCard({ book, readingStatus }: BookCardProps) {
const { t } = useTranslation();
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
const status = readingStatus ?? book.reading_status;
const overlay = status ? readingStatusOverlay[status] : null;
const overlayClass = status ? readingStatusOverlayClasses[status] : null;
const statusLabels: Record<ReadingStatus, string> = {
unread: t("status.unread"),
reading: t("status.reading"),
read: t("status.read"),
};
const isRead = status === "read";
@@ -71,11 +77,11 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
<div className="relative">
<BookImage
src={coverUrl}
alt={`Cover of ${book.title}`}
alt={t("books.coverOf", { name: book.title })}
/>
{overlay && (
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>
{overlay.label}
{overlayClass && status && (
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlayClass}`}>
{statusLabels[status]}
</span>
)}
</div>
@@ -108,6 +114,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
${(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' : ''}
${(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' : ''}
${(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
${(book.format ?? book.kind) === 'epub' ? 'bg-info/10 text-info' : ''}
`}>
{book.format ?? book.kind}
</span>
@@ -121,7 +128,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
</div>
</Link>
);
}
});
interface BooksGridProps {
books: (BookDto & { coverUrl?: string })[];

View File

@@ -2,10 +2,12 @@
import { useState } from "react";
import Image from "next/image";
import { useTranslation } from "../../lib/i18n/context";
const PAGE_SIZE = 5;
export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount: number }) {
const { t } = useTranslation();
const [offset, setOffset] = useState(0);
const pages = Array.from({ length: PAGE_SIZE }, (_, i) => offset + i + 1).filter(
@@ -16,9 +18,9 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">
Preview
{t("bookPreview.preview")}
<span className="ml-2 text-sm font-normal text-muted-foreground">
pages {offset + 1}{Math.min(offset + PAGE_SIZE, pageCount)} / {pageCount}
{t("bookPreview.pages", { start: offset + 1, end: Math.min(offset + PAGE_SIZE, pageCount), total: pageCount })}
</span>
</h2>
<div className="flex gap-2">
@@ -27,14 +29,14 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
disabled={offset === 0}
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Prev
{t("bookPreview.prev")}
</button>
<button
onClick={() => setOffset((o) => Math.min(o + PAGE_SIZE, pageCount - 1))}
disabled={offset + PAGE_SIZE >= pageCount}
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
{t("bookPreview.next")}
</button>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { Button } from "./ui";
import { useTranslation } from "../../lib/i18n/context";
interface ConvertButtonProps {
bookId: string;
@@ -15,6 +16,7 @@ type ConvertState =
| { type: "error"; message: string };
export function ConvertButton({ bookId }: ConvertButtonProps) {
const { t } = useTranslation();
const [state, setState] = useState<ConvertState>({ type: "idle" });
const handleConvert = async () => {
@@ -23,22 +25,22 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
const res = await fetch(`/api/books/${bookId}/convert`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
setState({ type: "error", message: body.error || "Conversion failed" });
setState({ type: "error", message: body.error || t("convert.failed") });
return;
}
const job = await res.json();
setState({ type: "success", jobId: job.id });
} catch (err) {
setState({ type: "error", message: err instanceof Error ? err.message : "Unknown error" });
setState({ type: "error", message: err instanceof Error ? err.message : t("convert.unknownError") });
}
};
if (state.type === "success") {
return (
<div className="flex items-center gap-2 text-sm text-success">
<span>Conversion started.</span>
<span>{t("convert.started")}</span>
<Link href={`/jobs/${state.jobId}`} className="text-primary hover:underline font-medium">
View job
{t("convert.viewJob")}
</Link>
</div>
);
@@ -52,7 +54,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
className="text-xs text-muted-foreground hover:underline text-left"
onClick={() => setState({ type: "idle" })}
>
Dismiss
{t("common.close")}
</button>
</div>
);
@@ -65,7 +67,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
onClick={handleConvert}
disabled={state.type === "loading"}
>
{state.type === "loading" ? "Converting" : "Convert to CBZ"}
{state.type === "loading" ? t("convert.converting") : t("convert.convertToCbz")}
</Button>
);
}

View File

@@ -0,0 +1,231 @@
"use client";
import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
AreaChart, Area, Line, LineChart,
Legend,
} from "recharts";
// ---------------------------------------------------------------------------
// Donut
// ---------------------------------------------------------------------------
export function RcDonutChart({
data,
noDataLabel,
}: {
data: { name: string; value: number; color: string }[];
noDataLabel?: string;
}) {
const total = data.reduce((s, d) => s + d.value, 0);
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
return (
<div className="flex items-center gap-4">
<ResponsiveContainer width={130} height={130}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={32}
outerRadius={55}
dataKey="value"
strokeWidth={0}
>
{data.map((d, i) => (
<Cell key={i} fill={d.color} />
))}
</Pie>
<Tooltip
formatter={(value) => value}
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
</PieChart>
</ResponsiveContainer>
<div className="flex flex-col gap-1.5 min-w-0">
{data.map((d, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
<span className="text-muted-foreground truncate">{d.name}</span>
<span className="font-medium text-foreground ml-auto">{d.value}</span>
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bar chart
// ---------------------------------------------------------------------------
export function RcBarChart({
data,
color = "hsl(198 78% 37%)",
noDataLabel,
}: {
data: { label: string; value: number }[];
color?: string;
noDataLabel?: string;
}) {
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
return (
<ResponsiveContainer width="100%" height={180}>
<BarChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Area / Line chart
// ---------------------------------------------------------------------------
export function RcAreaChart({
data,
color = "hsl(142 60% 45%)",
noDataLabel,
}: {
data: { label: string; value: number }[];
color?: string;
noDataLabel?: string;
}) {
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
return (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={2} fill="url(#areaGradient)" dot={{ r: 3, fill: color }} />
</AreaChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Horizontal stacked bar (libraries breakdown)
// ---------------------------------------------------------------------------
export function RcStackedBar({
data,
labels,
}: {
data: { name: string; read: number; reading: number; unread: number; sizeLabel: string }[];
labels: { read: string; reading: string; unread: string; books: string };
}) {
if (data.length === 0) return null;
return (
<ResponsiveContainer width="100%" height={data.length * 60 + 30}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 5, bottom: 0, left: 5 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis type="number" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 12, fill: "var(--color-foreground)" }} axisLine={false} tickLine={false} width={120} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(value: string) => <span className="text-muted-foreground">{value}</span>}
/>
<Bar dataKey="read" stackId="a" fill="hsl(142 60% 45%)" name={labels.read} radius={[0, 0, 0, 0]} />
<Bar dataKey="reading" stackId="a" fill="hsl(45 93% 47%)" name={labels.reading} />
<Bar dataKey="unread" stackId="a" fill="hsl(220 13% 70%)" name={labels.unread} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Horizontal bar chart (top series)
// ---------------------------------------------------------------------------
export function RcHorizontalBar({
data,
color = "hsl(142 60% 45%)",
noDataLabel,
}: {
data: { name: string; value: number; subLabel: string }[];
color?: string;
noDataLabel?: string;
}) {
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-4">{noDataLabel}</p>;
return (
<ResponsiveContainer width="100%" height={data.length * 40 + 10}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 5, bottom: 0, left: 5 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis type="number" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "var(--color-foreground)" }} axisLine={false} tickLine={false} width={120} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Bar dataKey="value" fill={color} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Multi-line chart (jobs over time)
// ---------------------------------------------------------------------------
export function RcMultiLineChart({
data,
lines,
noDataLabel,
}: {
data: Record<string, unknown>[];
lines: { key: string; label: string; color: string }[];
noDataLabel?: string;
}) {
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
if (data.length === 0 || !hasData)
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
return (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
{lines.map((l) => (
<Line
key={l.key}
type="monotone"
dataKey={l.key}
name={l.label}
stroke={l.color}
strokeWidth={2}
dot={{ r: 3, fill: l.color }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { BookDto } from "@/lib/api";
import { FormField, FormLabel, FormInput } from "./ui/Form";
import { useTranslation } from "../../lib/i18n/context";
function LockButton({
locked,
@@ -15,6 +16,7 @@ function LockButton({
onToggle: () => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<button
type="button"
@@ -25,7 +27,7 @@ function LockButton({
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground/40 hover:text-muted-foreground"
}`}
title={locked ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
>
{locked ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -45,6 +47,7 @@ interface EditBookFormProps {
}
export function EditBookForm({ book }: EditBookFormProps) {
const { t } = useTranslation();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
@@ -139,13 +142,13 @@ export function EditBookForm({ book }: EditBookFormProps) {
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur lors de la sauvegarde");
setError(data.error ?? t("editBook.saveError"));
return;
}
setIsOpen(false);
router.refresh();
} catch {
setError("Erreur réseau");
setError(t("common.networkError"));
}
});
};
@@ -163,7 +166,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">Modifier les métadonnées</h3>
<h3 className="font-semibold text-foreground">{t("editBook.editMetadata")}</h3>
<button
type="button"
onClick={handleClose}
@@ -181,21 +184,21 @@ export function EditBookForm({ book }: EditBookFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
<FormLabel required>Titre</FormLabel>
<FormLabel required>{t("editBook.title")}</FormLabel>
<LockButton locked={!!lockedFields.title} onToggle={() => toggleLock("title")} disabled={isPending} />
</div>
<FormInput
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isPending}
placeholder="Titre du livre"
placeholder={t("editBook.titlePlaceholder")}
/>
</FormField>
{/* Auteurs — multi-valeur */}
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
<FormLabel>Auteur(s)</FormLabel>
<FormLabel>{t("editBook.authors")}</FormLabel>
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
</div>
<div className="space-y-2">
@@ -212,7 +215,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
onClick={() => removeAuthor(i)}
disabled={isPending}
className="hover:text-destructive transition-colors ml-0.5"
aria-label={`Supprimer ${a}`}
aria-label={t("editBook.removeAuthor", { name: a })}
>
×
</button>
@@ -227,7 +230,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
onChange={(e) => setAuthorInput(e.target.value)}
onKeyDown={handleAuthorKeyDown}
disabled={isPending}
placeholder="Ajouter un auteur (Entrée pour valider)"
placeholder={t("editBook.addAuthor")}
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
@@ -244,33 +247,33 @@ export function EditBookForm({ book }: EditBookFormProps) {
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Langue</FormLabel>
<FormLabel>{t("editBook.language")}</FormLabel>
<LockButton locked={!!lockedFields.language} onToggle={() => toggleLock("language")} disabled={isPending} />
</div>
<FormInput
value={language}
onChange={(e) => setLanguage(e.target.value)}
disabled={isPending}
placeholder="ex : fr, en, jp"
placeholder={t("editBook.languagePlaceholder")}
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Série</FormLabel>
<FormLabel>{t("editBook.series")}</FormLabel>
<LockButton locked={!!lockedFields.series} onToggle={() => toggleLock("series")} disabled={isPending} />
</div>
<FormInput
value={series}
onChange={(e) => setSeries(e.target.value)}
disabled={isPending}
placeholder="Nom de la série"
placeholder={t("editBook.seriesPlaceholder")}
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Volume</FormLabel>
<FormLabel>{t("editBook.volume")}</FormLabel>
<LockButton locked={!!lockedFields.volume} onToggle={() => toggleLock("volume")} disabled={isPending} />
</div>
<FormInput
@@ -279,13 +282,13 @@ export function EditBookForm({ book }: EditBookFormProps) {
value={volume}
onChange={(e) => setVolume(e.target.value)}
disabled={isPending}
placeholder="Numéro de volume"
placeholder={t("editBook.volumePlaceholder")}
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>ISBN</FormLabel>
<FormLabel>{t("editBook.isbn")}</FormLabel>
<LockButton locked={!!lockedFields.isbn} onToggle={() => toggleLock("isbn")} disabled={isPending} />
</div>
<FormInput
@@ -298,27 +301,27 @@ export function EditBookForm({ book }: EditBookFormProps) {
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Date de publication</FormLabel>
<FormLabel>{t("editBook.publishDate")}</FormLabel>
<LockButton locked={!!lockedFields.publish_date} onToggle={() => toggleLock("publish_date")} disabled={isPending} />
</div>
<FormInput
value={publishDate}
onChange={(e) => setPublishDate(e.target.value)}
disabled={isPending}
placeholder="ex : 2023-01-15"
placeholder={t("editBook.publishDatePlaceholder")}
/>
</FormField>
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
<FormLabel>Description</FormLabel>
<FormLabel>{t("editBook.description")}</FormLabel>
<LockButton locked={!!lockedFields.summary} onToggle={() => toggleLock("summary")} disabled={isPending} />
</div>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
disabled={isPending}
placeholder="Résumé / description du livre"
placeholder={t("editBook.descriptionPlaceholder")}
rows={4}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
/>
@@ -331,7 +334,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
{t("editBook.lockedFieldsNote")}
</p>
)}
@@ -347,14 +350,14 @@ export function EditBookForm({ book }: EditBookFormProps) {
disabled={isPending}
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Annuler
{t("common.cancel")}
</button>
<button
type="submit"
disabled={isPending || !title.trim()}
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? "Sauvegarde…" : "Sauvegarder"}
{isPending ? t("editBook.savingLabel") : t("editBook.saveLabel")}
</button>
</div>
</form>
@@ -370,7 +373,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
onClick={() => setIsOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<span></span> Modifier
<span></span> {t("editBook.editMetadata")}
</button>
{modal}
</>

View File

@@ -4,6 +4,7 @@ import { useState, useTransition, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { FormField, FormLabel, FormInput } from "./ui/Form";
import { useTranslation } from "../../lib/i18n/context";
function LockButton({
locked,
@@ -14,6 +15,7 @@ function LockButton({
onToggle: () => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<button
type="button"
@@ -24,7 +26,7 @@ function LockButton({
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground/40 hover:text-muted-foreground"
}`}
title={locked ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
>
{locked ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -39,14 +41,7 @@ function LockButton({
);
}
const SERIES_STATUSES = [
{ value: "", label: "Non défini" },
{ value: "ongoing", label: "En cours" },
{ value: "ended", label: "Terminée" },
{ value: "hiatus", label: "Hiatus" },
{ value: "cancelled", label: "Annulée" },
{ value: "upcoming", label: "À paraître" },
] as const;
const SERIES_STATUS_VALUES = ["", "ongoing", "ended", "hiatus", "cancelled", "upcoming"] as const;
interface EditSeriesFormProps {
libraryId: string;
@@ -75,6 +70,7 @@ export function EditSeriesForm({
currentStatus,
currentLockedFields,
}: EditSeriesFormProps) {
const { t } = useTranslation();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
@@ -213,7 +209,7 @@ export function EditSeriesForm({
);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur lors de la sauvegarde");
setError(data.error ?? t("editBook.saveError"));
return;
}
setIsOpen(false);
@@ -224,7 +220,7 @@ export function EditSeriesForm({
router.refresh();
}
} catch {
setError("Erreur réseau");
setError(t("common.networkError"));
}
});
};
@@ -242,7 +238,7 @@ export function EditSeriesForm({
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">Modifier la série</h3>
<h3 className="font-semibold text-foreground">{t("editSeries.title")}</h3>
<button
type="button"
onClick={handleClose}
@@ -259,18 +255,18 @@ export function EditSeriesForm({
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField>
<FormLabel required>Nom</FormLabel>
<FormLabel required>{t("editSeries.name")}</FormLabel>
<FormInput
value={newName}
onChange={(e) => setNewName(e.target.value)}
disabled={isPending}
placeholder="Nom de la série"
placeholder={t("editSeries.namePlaceholder")}
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Année de début</FormLabel>
<FormLabel>{t("editSeries.startYear")}</FormLabel>
<LockButton locked={!!lockedFields.start_year} onToggle={() => toggleLock("start_year")} disabled={isPending} />
</div>
<FormInput
@@ -280,13 +276,13 @@ export function EditSeriesForm({
value={startYear}
onChange={(e) => setStartYear(e.target.value)}
disabled={isPending}
placeholder="ex : 1990"
placeholder={t("editSeries.startYearPlaceholder")}
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Nombre de volumes</FormLabel>
<FormLabel>{t("editSeries.totalVolumes")}</FormLabel>
<LockButton locked={!!lockedFields.total_volumes} onToggle={() => toggleLock("total_volumes")} disabled={isPending} />
</div>
<FormInput
@@ -295,13 +291,13 @@ export function EditSeriesForm({
value={totalVolumes}
onChange={(e) => setTotalVolumes(e.target.value)}
disabled={isPending}
placeholder="ex : 12"
placeholder="12"
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Statut</FormLabel>
<FormLabel>{t("editSeries.status")}</FormLabel>
<LockButton locked={!!lockedFields.status} onToggle={() => toggleLock("status")} disabled={isPending} />
</div>
<select
@@ -310,8 +306,10 @@ export function EditSeriesForm({
disabled={isPending}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
>
{SERIES_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
{SERIES_STATUS_VALUES.map((v) => (
<option key={v} value={v}>
{v === "" ? t("seriesStatus.notDefined") : t(`seriesStatus.${v}` as any)}
</option>
))}
</select>
</FormField>
@@ -319,7 +317,7 @@ export function EditSeriesForm({
{/* Auteurs — multi-valeur */}
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
<FormLabel>Auteur(s)</FormLabel>
<FormLabel>{t("editSeries.authors")}</FormLabel>
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
</div>
<div className="space-y-2">
@@ -336,7 +334,7 @@ export function EditSeriesForm({
onClick={() => removeAuthor(i)}
disabled={isPending}
className="hover:text-destructive transition-colors ml-0.5"
aria-label={`Supprimer ${a}`}
aria-label={t("editBook.removeAuthor", { name: a })}
>
×
</button>
@@ -351,7 +349,7 @@ export function EditSeriesForm({
onChange={(e) => setAuthorInput(e.target.value)}
onKeyDown={handleAuthorKeyDown}
disabled={isPending}
placeholder="Ajouter un auteur (Entrée pour valider)"
placeholder={t("editBook.addAuthor")}
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
@@ -371,9 +369,9 @@ export function EditSeriesForm({
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground"
}`}
title="Appliquer auteur et langue à tous les livres de la série"
title={t("editSeries.applyToBooksTitle")}
>
livres
{t("editSeries.applyToBooks")}
</button>
</div>
</div>
@@ -382,21 +380,21 @@ export function EditSeriesForm({
{showApplyToBooks && (
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3 pl-4 border-l-2 border-primary/30">
<FormField>
<FormLabel>Auteur (livres)</FormLabel>
<FormLabel>{t("editSeries.bookAuthor")}</FormLabel>
<FormInput
value={bookAuthor}
onChange={(e) => setBookAuthor(e.target.value)}
disabled={isPending}
placeholder="Écrase le champ auteur de chaque livre"
placeholder={t("editSeries.bookAuthorPlaceholder")}
/>
</FormField>
<FormField>
<FormLabel>Langue (livres)</FormLabel>
<FormLabel>{t("editSeries.bookLanguage")}</FormLabel>
<FormInput
value={bookLanguage}
onChange={(e) => setBookLanguage(e.target.value)}
disabled={isPending}
placeholder="ex : fr, en, jp"
placeholder={t("editBook.languagePlaceholder")}
/>
</FormField>
</div>
@@ -405,7 +403,7 @@ export function EditSeriesForm({
{/* Éditeurs — multi-valeur */}
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
<FormLabel>Éditeur(s)</FormLabel>
<FormLabel>{t("editSeries.publishers")}</FormLabel>
<LockButton locked={!!lockedFields.publishers} onToggle={() => toggleLock("publishers")} disabled={isPending} />
</div>
<div className="space-y-2">
@@ -422,7 +420,7 @@ export function EditSeriesForm({
onClick={() => removePublisher(i)}
disabled={isPending}
className="hover:text-destructive transition-colors ml-0.5"
aria-label={`Supprimer ${p}`}
aria-label={t("editBook.removeAuthor", { name: p })}
>
×
</button>
@@ -437,7 +435,7 @@ export function EditSeriesForm({
onChange={(e) => setPublisherInput(e.target.value)}
onKeyDown={handlePublisherKeyDown}
disabled={isPending}
placeholder="Ajouter un éditeur (Entrée pour valider)"
placeholder={t("editSeries.addPublisher")}
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
@@ -454,7 +452,7 @@ export function EditSeriesForm({
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
<FormLabel>Description</FormLabel>
<FormLabel>{t("editBook.description")}</FormLabel>
<LockButton locked={!!lockedFields.description} onToggle={() => toggleLock("description")} disabled={isPending} />
</div>
<textarea
@@ -462,7 +460,7 @@ export function EditSeriesForm({
onChange={(e) => setDescription(e.target.value)}
disabled={isPending}
rows={3}
placeholder="Synopsis ou description de la série…"
placeholder={t("editSeries.descriptionPlaceholder")}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</FormField>
@@ -474,7 +472,7 @@ export function EditSeriesForm({
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
{t("editBook.lockedFieldsNote")}
</p>
)}
@@ -488,14 +486,14 @@ export function EditSeriesForm({
disabled={isPending}
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Annuler
{t("common.cancel")}
</button>
<button
type="submit"
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? "Sauvegarde…" : "Sauvegarder"}
{isPending ? t("common.saving") : t("common.save")}
</button>
</div>
</form>
@@ -511,7 +509,7 @@ export function EditSeriesForm({
onClick={() => setIsOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<span></span> Modifier la série
<span></span> {t("editSeries.title")}
</button>
{modal}
</>

View File

@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { FolderItem } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context";
interface TreeNode extends FolderItem {
children?: TreeNode[];
@@ -15,6 +16,7 @@ interface FolderBrowserProps {
}
export function FolderBrowser({ initialFolders, selectedPath, onSelect }: FolderBrowserProps) {
const { t } = useTranslation();
// Convert initial folders to tree structure
const [tree, setTree] = useState<TreeNode[]>(
initialFolders.map(f => ({ ...f, children: f.has_children ? [] : undefined }))
@@ -173,7 +175,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
<div className="max-h-80 overflow-y-auto">
{tree.length === 0 ? (
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
No folders found
{t("folder.noFolders")}
</div>
) : (
tree.map(node => renderNode(node))

View File

@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { FolderBrowser } from "./FolderBrowser";
import { FolderItem } from "../../lib/api";
import { Button } from "./ui";
import { useTranslation } from "../../lib/i18n/context";
interface FolderPickerProps {
initialFolders: FolderItem[];
@@ -13,6 +15,7 @@ interface FolderPickerProps {
export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
const handleSelect = (path: string) => {
onSelect(path);
@@ -27,7 +30,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
<input
type="text"
readOnly
value={selectedPath || "Select a folder..."}
value={selectedPath || t("folder.selectFolder")}
className={`
w-full px-3 py-2 rounded-lg border bg-card
text-sm font-mono
@@ -57,12 +60,12 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
Browse
{t("common.browse")}
</Button>
</div>
{/* Popup Modal */}
{isOpen && (
{isOpen && createPortal(
<>
{/* Backdrop */}
<div
@@ -79,7 +82,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="font-medium">Select Folder</span>
<span className="font-medium">{t("folder.selectFolderTitle")}</span>
</div>
<button
type="button"
@@ -104,7 +107,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
<span className="text-xs text-muted-foreground">
Click a folder to select it
{t("folder.clickToSelect")}
</span>
<div className="flex gap-2">
<Button
@@ -113,13 +116,14 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
size="sm"
onClick={() => setIsOpen(false)}
>
Cancel
{t("common.cancel")}
</Button>
</div>
</div>
</div>
</div>
</>
</>,
document.body
)}
</div>
);

View File

@@ -0,0 +1,44 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
interface JobDetailLiveProps {
jobId: string;
isTerminal: boolean;
}
export function JobDetailLive({ jobId, isTerminal }: JobDetailLiveProps) {
const router = useRouter();
const isTerminalRef = useRef(isTerminal);
isTerminalRef.current = isTerminal;
useEffect(() => {
if (isTerminalRef.current) return;
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
router.refresh();
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
eventSource.close();
}
} catch {
// ignore parse errors
}
};
eventSource.onerror = () => {
eventSource.close();
};
return () => {
eventSource.close();
};
}, [jobId, router]);
return null;
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "../../lib/i18n/context";
import { StatusBadge, Badge, ProgressBar } from "./ui";
interface ProgressEvent {
@@ -24,6 +25,7 @@ interface JobProgressProps {
}
export function JobProgress({ jobId, onComplete }: JobProgressProps) {
const { t } = useTranslation();
const [progress, setProgress] = useState<ProgressEvent | null>(null);
const [error, setError] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
@@ -53,25 +55,25 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
onComplete?.();
}
} catch (err) {
setError("Failed to parse SSE data");
setError(t("jobProgress.sseError"));
}
};
eventSource.onerror = (err) => {
console.error("SSE error:", err);
eventSource.close();
setError("Connection lost");
setError(t("jobProgress.connectionLost"));
};
return () => {
eventSource.close();
};
}, [jobId, onComplete]);
}, [jobId, onComplete, t]);
if (error) {
return (
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
Error: {error}
{t("jobProgress.error", { message: error })}
</div>
);
}
@@ -79,7 +81,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
if (!progress) {
return (
<div className="p-4 text-muted-foreground text-sm">
Loading progress...
{t("jobProgress.loadingProgress")}
</div>
);
}
@@ -88,14 +90,14 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
const processed = progress.processed_files ?? 0;
const total = progress.total_files ?? 0;
const isPhase2 = progress.status === "extracting_pages" || progress.status === "generating_thumbnails";
const unitLabel = progress.status === "extracting_pages" ? "pages" : progress.status === "generating_thumbnails" ? "thumbnails" : "files";
const unitLabel = progress.status === "extracting_pages" ? t("jobProgress.pages") : progress.status === "generating_thumbnails" ? t("jobProgress.thumbnails") : t("jobProgress.filesUnit");
return (
<div className="p-4 bg-card rounded-lg border border-border">
<div className="flex items-center justify-between mb-3">
<StatusBadge status={progress.status} />
{isComplete && (
<Badge variant="success">Complete</Badge>
<Badge variant="success">{t("jobProgress.done")}</Badge>
)}
</div>
@@ -105,20 +107,20 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
<span>{processed} / {total} {unitLabel}</span>
{progress.current_file && (
<span className="truncate max-w-md" title={progress.current_file}>
Current: {progress.current_file.length > 40
{t("jobProgress.currentFile", { file: progress.current_file.length > 40
? progress.current_file.substring(0, 40) + "..."
: progress.current_file}
: progress.current_file })}
</span>
)}
</div>
{progress.stats_json && !isPhase2 && (
<div className="flex flex-wrap gap-3 text-xs">
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
<Badge variant="primary">{t("jobProgress.scanned", { count: progress.stats_json.scanned_files })}</Badge>
<Badge variant="success">{t("jobProgress.indexed", { count: progress.stats_json.indexed_files })}</Badge>
<Badge variant="warning">{t("jobProgress.removed", { count: progress.stats_json.removed_files })}</Badge>
{progress.stats_json.errors > 0 && (
<Badge variant="error">Errors: {progress.stats_json.errors}</Badge>
<Badge variant="error">{t("jobProgress.errors", { count: progress.stats_json.errors })}</Badge>
)}
</div>
)}

View File

@@ -2,8 +2,9 @@
import { useState } from "react";
import Link from "next/link";
import { useTranslation } from "../../lib/i18n/context";
import { JobProgress } from "./JobProgress";
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar } from "./ui";
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon, Tooltip } from "./ui";
interface JobRowProps {
job: {
@@ -20,6 +21,7 @@ interface JobRowProps {
indexed_files: number;
removed_files: number;
errors: number;
refreshed?: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
@@ -33,6 +35,7 @@ interface JobRowProps {
}
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
const { t } = useTranslation();
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
const [showProgress, setShowProgress] = useState(highlighted || isActive);
@@ -57,28 +60,11 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
const hasThumbnailPhase = isPhase2 || isThumbnailJob;
// Files column: index-phase stats only (Phase 1 discovery)
const filesDisplay =
job.status === "running" && !isPhase2
? job.total_files != null
? `${job.processed_files ?? 0}/${job.total_files}`
: scanned > 0
? `${scanned} scanned`
: "-"
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
? null // rendered below as ✓ / / ⚠
: scanned > 0
? `${scanned} scanned`
: "—";
const isMetadataBatch = job.type === "metadata_batch";
const isMetadataRefresh = job.type === "metadata_refresh";
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
// Thumbnails progress (Phase 2: extracting_pages + generating_thumbnails)
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isPhase2);
const thumbDisplay =
thumbInProgress && job.total_files != null
? `${job.processed_files ?? 0}/${job.total_files}`
: job.status === "success" && job.total_files != null && hasThumbnailPhase
? `${job.total_files}`
: "—";
return (
<>
@@ -113,32 +99,99 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
className="text-xs text-primary hover:text-primary/80 hover:underline"
onClick={() => setShowProgress(!showProgress)}
>
{showProgress ? "Hide" : "Show"} progress
{showProgress ? t("jobRow.hideProgress") : t("jobRow.showProgress")}
</button>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
{filesDisplay !== null ? (
<span className="text-sm text-foreground">{filesDisplay}</span>
) : (
<div className="flex items-center gap-2 text-xs">
<span className="text-success"> {indexed}</span>
{removed > 0 && <span className="text-warning"> {removed}</span>}
{errors > 0 && <span className="text-error"> {errors}</span>}
</div>
)}
{job.status === "running" && !isPhase2 && job.total_files != null && (
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
)}
</div>
</td>
<td className="px-4 py-3">
{/* Running progress */}
{isActive && job.total_files != null && (
<div className="flex flex-col gap-1">
<span className="text-sm text-foreground">{thumbDisplay}</span>
{thumbInProgress && job.total_files != null && (
<span className="text-sm text-foreground">{job.processed_files ?? 0}/{job.total_files}</span>
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
</div>
)}
{/* Completed stats with icons */}
{!isActive && (
<div className="flex items-center gap-3 text-xs">
{/* Files: indexed count */}
{indexed > 0 && (
<Tooltip label={t("jobRow.filesIndexed", { count: indexed })}>
<span className="inline-flex items-center gap-1 text-success">
<Icon name="document" size="sm" />
{indexed}
</span>
</Tooltip>
)}
{/* Removed files */}
{removed > 0 && (
<Tooltip label={t("jobRow.filesRemoved", { count: removed })}>
<span className="inline-flex items-center gap-1 text-warning">
<Icon name="trash" size="sm" />
{removed}
</span>
</Tooltip>
)}
{/* Thumbnails */}
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
<Tooltip label={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-primary">
<Icon name="image" size="sm" />
{job.total_files}
</span>
</Tooltip>
)}
{/* Metadata batch: series processed */}
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
<Tooltip label={t("jobRow.metadataProcessed", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-info">
<Icon name="tag" size="sm" />
{job.total_files}
</span>
</Tooltip>
)}
{/* Metadata refresh: total links + refreshed count */}
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
<Tooltip label={t("jobRow.metadataLinks", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-info">
<Icon name="tag" size="sm" />
{job.total_files}
</span>
</Tooltip>
)}
{isMetadataRefresh && job.stats_json?.refreshed != null && job.stats_json.refreshed > 0 && (
<Tooltip label={t("jobRow.metadataRefreshed", { count: job.stats_json.refreshed })}>
<span className="inline-flex items-center gap-1 text-success">
<Icon name="refresh" size="sm" />
{job.stats_json.refreshed}
</span>
</Tooltip>
)}
{/* Errors */}
{errors > 0 && (
<Tooltip label={t("jobRow.errors", { count: errors })}>
<span className="inline-flex items-center gap-1 text-error">
<Icon name="warning" size="sm" />
{errors}
</span>
</Tooltip>
)}
{/* Scanned only (no other stats) */}
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
<Tooltip label={t("jobRow.scanned", { count: scanned })}>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Icon name="search" size="sm" />
{scanned}
</span>
</Tooltip>
)}
{/* Nothing to show */}
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
)}
</div>
</td>
@@ -152,17 +205,24 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
<div className="flex items-center gap-2">
<Link
href={`/jobs/${job.id}`}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
className="inline-flex items-center justify-center gap-1.5 h-7 px-2.5 text-xs font-medium rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
>
View
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{t("jobRow.view")}
</Link>
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
<Button
variant="danger"
size="sm"
size="xs"
onClick={() => onCancel(job.id)}
>
Cancel
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
{t("common.cancel")}
</Button>
)}
</div>
@@ -170,7 +230,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</tr>
{showProgress && isActive && (
<tr>
<td colSpan={9} className="px-4 py-3 bg-muted/50">
<td colSpan={8} className="px-4 py-3 bg-muted/50">
<JobProgress
jobId={job.id}
onComplete={handleComplete}

View File

@@ -3,6 +3,7 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { useTranslation } from "../../lib/i18n/context";
import { Badge } from "./ui/Badge";
import { ProgressBar } from "./ui/ProgressBar";
@@ -45,6 +46,7 @@ const ChevronIcon = ({ className }: { className?: string }) => (
);
export function JobsIndicator() {
const { t } = useTranslation();
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -52,21 +54,62 @@ export function JobsIndicator() {
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
useEffect(() => {
const fetchActiveJobs = async () => {
try {
const response = await fetch("/api/jobs/active");
if (response.ok) {
const jobs = await response.json();
setActiveJobs(jobs);
let eventSource: EventSource | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
const connect = () => {
if (eventSource) {
eventSource.close();
}
} catch (error) {
console.error("Failed to fetch jobs:", error);
eventSource = new EventSource("/api/jobs/stream");
eventSource.onmessage = (event) => {
try {
const allJobs: Job[] = JSON.parse(event.data);
const active = allJobs.filter(j =>
j.status === "running" || j.status === "pending" ||
j.status === "extracting_pages" || j.status === "generating_thumbnails"
);
setActiveJobs(active);
} catch {
// ignore malformed data
}
};
fetchActiveJobs();
const interval = setInterval(fetchActiveJobs, 2000);
return () => clearInterval(interval);
eventSource.onerror = () => {
eventSource?.close();
eventSource = null;
// Reconnect after 5s on error
reconnectTimeout = setTimeout(connect, 5000);
};
};
const disconnect = () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
};
const handleVisibilityChange = () => {
if (document.hidden) {
disconnect();
} else {
connect();
}
};
connect();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
disconnect();
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
// Position the popin relative to the button
@@ -152,7 +195,7 @@ export function JobsIndicator() {
hover:bg-accent
transition-colors duration-200
"
title="View all jobs"
title={t("jobsIndicator.viewAll")}
>
<JobsIcon className="w-[18px] h-[18px]" />
</Link>
@@ -187,11 +230,11 @@ export function JobsIndicator() {
<div className="flex items-center gap-3">
<span className="text-xl">📊</span>
<div>
<h3 className="font-semibold text-foreground">Active Jobs</h3>
<h3 className="font-semibold text-foreground">{t("jobsIndicator.activeTasks")}</h3>
<p className="text-xs text-muted-foreground">
{runningJobs.length > 0
? `${runningJobs.length} running, ${pendingJobs.length} pending`
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
? t("jobsIndicator.runningAndPending", { running: runningJobs.length, pending: pendingJobs.length })
: t("jobsIndicator.pendingTasks", { count: pendingJobs.length, plural: pendingJobs.length !== 1 ? "s" : "" })
}
</p>
</div>
@@ -201,7 +244,7 @@ export function JobsIndicator() {
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
onClick={() => setIsOpen(false)}
>
View All
{t("jobsIndicator.viewAllLink")}
</Link>
</div>
@@ -209,7 +252,7 @@ export function JobsIndicator() {
{runningJobs.length > 0 && (
<div className="px-4 py-3 border-b border-border/60">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Overall Progress</span>
<span className="text-muted-foreground">{t("jobsIndicator.overallProgress")}</span>
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
</div>
<ProgressBar value={totalProgress} size="sm" variant="success" />
@@ -221,7 +264,7 @@ export function JobsIndicator() {
{activeJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<span className="text-4xl mb-2"></span>
<p>No active jobs</p>
<p>{t("jobsIndicator.noActiveTasks")}</p>
</div>
) : (
<ul className="divide-y divide-border/60">
@@ -242,7 +285,7 @@ export function JobsIndicator() {
<div className="flex items-center gap-2 mb-1">
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
<Badge variant={job.type === 'rebuild' ? 'primary' : job.type === 'thumbnail_regenerate' ? 'warning' : 'secondary'} className="text-[10px]">
{job.type === 'thumbnail_rebuild' ? 'Thumbnails' : job.type === 'thumbnail_regenerate' ? 'Regenerate' : job.type}
{t(`jobType.${job.type}` as any) !== `jobType.${job.type}` ? t(`jobType.${job.type}` as any) : job.type}
</Badge>
</div>
@@ -281,7 +324,7 @@ export function JobsIndicator() {
{/* Footer */}
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
<p className="text-xs text-muted-foreground text-center">Auto-refreshing every 2s</p>
<p className="text-xs text-muted-foreground text-center">{t("jobsIndicator.autoRefresh")}</p>
</div>
</div>
</>
@@ -304,7 +347,7 @@ export function JobsIndicator() {
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
`}
onClick={() => setIsOpen(!isOpen)}
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
title={t("jobsIndicator.taskCount", { count: totalCount, plural: totalCount !== 1 ? "s" : "" })}
>
{/* Animated spinner for running jobs */}
{runningJobs.length > 0 && (

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslation } from "../../lib/i18n/context";
import { JobRow } from "./JobRow";
interface Job {
@@ -17,6 +18,7 @@ interface Job {
indexed_files: number;
removed_files: number;
errors: number;
refreshed?: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
@@ -39,26 +41,23 @@ function formatDuration(start: string, end: string | null): string {
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
return `${mins}m ago`;
}
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
}
return date.toLocaleDateString();
}
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
const { t, locale } = useTranslation();
const [jobs, setJobs] = useState(initialJobs);
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
const loc = locale === "fr" ? "fr-FR" : "en-US";
return date.toLocaleString(loc, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// Refresh jobs list via SSE
useEffect(() => {
const eventSource = new EventSource("/api/jobs/stream");
@@ -102,15 +101,14 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Library</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Thumbnails</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.id")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.library")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.type")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.status")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.stats")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.duration")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.created")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">

View File

@@ -1,8 +1,10 @@
"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import { useState, useTransition } from "react";
import { createPortal } from "react-dom";
import { Button } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon";
import { useTranslation } from "../../lib/i18n/context";
interface LibraryActionsProps {
libraryId: string;
@@ -10,6 +12,8 @@ interface LibraryActionsProps {
scanMode: string;
watcherEnabled: boolean;
metadataProvider: string | null;
fallbackMetadataProvider: string | null;
metadataRefreshMode: string;
onUpdate?: () => void;
}
@@ -19,22 +23,13 @@ export function LibraryActions({
scanMode,
watcherEnabled,
metadataProvider,
onUpdate
fallbackMetadataProvider,
metadataRefreshMode,
}: LibraryActionsProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [saveError, setSaveError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSubmit = (formData: FormData) => {
setSaveError(null);
@@ -43,6 +38,8 @@ export function LibraryActions({
const watcherEnabled = formData.get("watcher_enabled") === "true";
const scanMode = formData.get("scan_mode") as string;
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
const newMetadataRefreshMode = formData.get("metadata_refresh_mode") as string;
try {
const [response] = await Promise.all([
@@ -53,12 +50,13 @@ export function LibraryActions({
monitor_enabled: monitorEnabled,
scan_mode: scanMode,
watcher_enabled: watcherEnabled,
metadata_refresh_mode: newMetadataRefreshMode,
}),
}),
fetch(`/api/libraries/${libraryId}/metadata-provider`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ metadata_provider: newMetadataProvider }),
body: JSON.stringify({ metadata_provider: newMetadataProvider, fallback_metadata_provider: newFallbackProvider }),
}),
]);
@@ -80,11 +78,11 @@ export function LibraryActions({
};
return (
<div className="relative" ref={dropdownRef}>
<>
<Button
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
onClick={() => setIsOpen(true)}
className={isOpen ? "bg-accent" : ""}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -93,12 +91,54 @@ export function LibraryActions({
</svg>
</Button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-md border border-border/60 p-4 z-50">
{isOpen && createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => setIsOpen(false)}
/>
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
<div className="flex items-center gap-2.5">
<svg className="w-5 h-5 text-primary" 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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>
<span className="font-semibold text-lg">{t("libraryActions.settingsTitle")}</span>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
>
<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>
{/* Form */}
<form action={handleSubmit}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto">
{/* Section: Indexation */}
<div className="space-y-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{t("libraryActions.sectionIndexation")}
</h3>
{/* Auto scan */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="monitor_enabled"
@@ -106,12 +146,25 @@ export function LibraryActions({
defaultChecked={monitorEnabled}
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
Auto Scan
{t("libraryActions.autoScan")}
</label>
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.autoScanDesc")}</p>
</div>
<select
name="scan_mode"
defaultValue={scanMode}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[130px] shrink-0"
>
<option value="manual">{t("monitoring.manual")}</option>
<option value="hourly">{t("monitoring.hourly")}</option>
<option value="daily">{t("monitoring.daily")}</option>
<option value="weekly">{t("monitoring.weekly")}</option>
</select>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
{/* File watcher */}
<div>
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="watcher_enabled"
@@ -119,35 +172,37 @@ export function LibraryActions({
defaultChecked={watcherEnabled}
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
File Watcher
{t("libraryActions.fileWatch")}
</label>
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.fileWatchDesc")}</p>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">📅 Schedule</label>
<select
name="scan_mode"
defaultValue={scanMode}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
<hr className="border-border/40" />
<div className="flex items-center justify-between">
{/* Section: Metadata */}
<div className="space-y-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{t("libraryActions.sectionMetadata")}
</h3>
{/* Provider */}
<div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
Metadata Provider
{metadataProvider && metadataProvider !== "none" && <ProviderIcon provider={metadataProvider} size={16} />}
{t("libraryActions.provider")}
</label>
<select
name="metadata_provider"
defaultValue={metadataProvider || ""}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
>
<option value="">Default</option>
<option value="">{t("libraryActions.default")}</option>
<option value="none">{t("libraryActions.none")}</option>
<option value="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
@@ -155,25 +210,82 @@ export function LibraryActions({
<option value="bedetheque">Bédéthèque</option>
</select>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.providerDesc")}</p>
</div>
{/* Fallback */}
<div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
{fallbackMetadataProvider && fallbackMetadataProvider !== "none" && <ProviderIcon provider={fallbackMetadataProvider} size={16} />}
{t("libraryActions.fallback")}
</label>
<select
name="fallback_metadata_provider"
defaultValue={fallbackMetadataProvider || ""}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
>
<option value="">{t("libraryActions.none")}</option>
<option value="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
<option value="anilist">AniList</option>
<option value="bedetheque">Bédéthèque</option>
</select>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.fallbackDesc")}</p>
</div>
{/* Metadata refresh */}
<div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm font-medium text-foreground">{t("libraryActions.metadataRefreshSchedule")}</label>
<select
name="metadata_refresh_mode"
defaultValue={metadataRefreshMode}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
>
<option value="manual">{t("monitoring.manual")}</option>
<option value="hourly">{t("monitoring.hourly")}</option>
<option value="daily">{t("monitoring.daily")}</option>
<option value="weekly">{t("monitoring.weekly")}</option>
</select>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.metadataRefreshDesc")}</p>
</div>
</div>
{saveError && (
<p className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
{saveError}
</p>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border/50 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsOpen(false)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
size="sm"
className="w-full"
disabled={isPending}
>
{isPending ? "Saving..." : "Save Settings"}
{isPending ? t("libraryActions.saving") : t("common.save")}
</Button>
</div>
</form>
</div>
)}
</div>
</>,
document.body
)}
</>
);
}

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { FolderPicker } from "./FolderPicker";
import { FolderItem } from "../../lib/api";
import { Button, FormField, FormInput, FormRow } from "./ui";
import { useTranslation } from "../../lib/i18n/context";
interface LibraryFormProps {
initialFolders: FolderItem[];
@@ -11,13 +12,14 @@ interface LibraryFormProps {
}
export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
const { t } = useTranslation();
const [selectedPath, setSelectedPath] = useState<string>("");
return (
<form action={action}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder="Library name" required />
<FormInput name="name" placeholder={t("libraries.libraryName")} required />
</FormField>
<FormField className="flex-1 min-w-64">
<input type="hidden" name="root_path" value={selectedPath} />
@@ -30,7 +32,7 @@ export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
</FormRow>
<div className="mt-4 flex justify-end">
<Button type="submit" disabled={!selectedPath}>
Add Library
{t("libraries.addButton")}
</Button>
</div>
</form>

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { Card, Badge } from "./ui";
import { getServerTranslations } from "../../lib/i18n/server";
interface LibrarySubPageHeaderProps {
library: {
@@ -19,13 +20,14 @@ interface LibrarySubPageHeaderProps {
};
}
export function LibrarySubPageHeader({
export async function LibrarySubPageHeader({
library,
title,
icon,
iconColor = "text-primary",
filterInfo
}: LibrarySubPageHeaderProps) {
const { t } = await getServerTranslations();
return (
<div className="space-y-6">
{/* Header avec breadcrumb intégré */}
@@ -38,7 +40,7 @@ export function LibrarySubPageHeader({
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Libraries
{t("libraryHeader.libraries")}
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-sm text-foreground font-medium">{library.name}</span>
@@ -73,8 +75,7 @@ export function LibrarySubPageHeader({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<span className="text-foreground">
<span className="font-semibold">{library.book_count}</span>
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted-foreground ml-1">{t("libraryHeader.bookCount", { count: library.book_count, plural: library.book_count !== 1 ? "s" : "" })}</span>
</span>
</div>
@@ -86,7 +87,7 @@ export function LibrarySubPageHeader({
variant={library.enabled ? "success" : "muted"}
className="text-xs"
>
{library.enabled ? "Enabled" : "Disabled"}
{library.enabled ? t("libraryHeader.enabled") : t("libraries.disabled")}
</Badge>
</div>
</div>

View File

@@ -2,6 +2,27 @@
import { useRef, useCallback, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "../../lib/i18n/context";
// SVG path data for filter icons, keyed by field name
const FILTER_ICONS: Record<string, string> = {
// Library - building/collection
library: "M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z",
// Reading status - open book
status: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253",
// Series status - signal/activity
series_status: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
// Missing books - warning triangle
has_missing: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
// Metadata provider - tag
metadata_provider: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
// Sort - arrows up/down
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
// Format - document/file
format: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z",
// Metadata - link/chain
metadata: "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",
};
interface FieldDef {
name: string;
@@ -18,11 +39,17 @@ interface LiveSearchFormProps {
debounceMs?: number;
}
const STORAGE_KEY_PREFIX = "filters:";
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const restoredRef = useRef(false);
const storageKey = `${STORAGE_KEY_PREFIX}${basePath}`;
const buildUrl = useCallback((): string => {
if (!formRef.current) return basePath;
@@ -36,16 +63,58 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
return qs ? `${basePath}?${qs}` : basePath;
}, [basePath]);
const saveFilters = useCallback(() => {
if (!formRef.current) return;
const formData = new FormData(formRef.current);
const filters: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
const str = value.toString().trim();
if (str) filters[key] = str;
}
try {
localStorage.setItem(storageKey, JSON.stringify(filters));
} catch {}
}, [storageKey]);
const navigate = useCallback((immediate: boolean) => {
if (timerRef.current) clearTimeout(timerRef.current);
if (immediate) {
saveFilters();
router.replace(buildUrl() as any);
} else {
timerRef.current = setTimeout(() => {
saveFilters();
router.replace(buildUrl() as any);
}, debounceMs);
}
}, [router, buildUrl, debounceMs]);
}, [router, buildUrl, debounceMs, saveFilters]);
// Restore filters from localStorage on mount if URL has no filters
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
const hasUrlFilters = fields.some((f) => {
const val = searchParams.get(f.name);
return val && val.trim() !== "";
});
if (hasUrlFilters) return;
try {
const saved = localStorage.getItem(storageKey);
if (!saved) return;
const filters: Record<string, string> = JSON.parse(saved);
const fieldNames = new Set(fields.map((f) => f.name));
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (fieldNames.has(key) && value) params.set(key, value);
}
const qs = params.toString();
if (qs) {
router.replace(`${basePath}?${qs}` as any);
}
} catch {}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
@@ -58,41 +127,64 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
return val && val.trim() !== "";
});
const textFields = fields.filter((f) => f.type === "text");
const selectFields = fields.filter((f) => f.type === "select");
return (
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
if (timerRef.current) clearTimeout(timerRef.current);
saveFilters();
router.replace(buildUrl() as any);
}}
className="flex flex-col sm:flex-row gap-3 items-start sm:items-end"
className="space-y-4"
>
{fields.map((field) =>
field.type === "text" ? (
<div key={field.name} className={field.className || "flex-1 w-full"}>
<label className="block text-sm font-medium text-foreground mb-1.5">
{field.label}
</label>
{/* Search input with icon */}
{textFields.map((field) => (
<div key={field.name} className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
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>
<input
name={field.name}
type="text"
placeholder={field.placeholder}
defaultValue={searchParams.get(field.name) || ""}
onChange={() => navigate(false)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="flex h-11 w-full rounded-lg border border-input bg-background pl-10 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
) : (
<div key={field.name} className={field.className || "w-full sm:w-48"}>
<label className="block text-sm font-medium text-foreground mb-1.5">
))}
{/* Filters row */}
{selectFields.length > 0 && (
<>
{textFields.length > 0 && (
<div className="border-t border-border/60" />
)}
<div className="flex flex-wrap gap-3 items-center">
{selectFields.map((field) => (
<div key={field.name} className="flex items-center gap-1.5">
{FILTER_ICONS[field.name] && (
<svg className="w-3.5 h-3.5 text-muted-foreground shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={FILTER_ICONS[field.name]} />
</svg>
)}
<label className="text-xs font-medium text-muted-foreground whitespace-nowrap">
{field.label}
</label>
<select
name={field.name}
defaultValue={searchParams.get(field.name) || ""}
onChange={() => navigate(true)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="h-8 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -101,28 +193,34 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
))}
</select>
</div>
)
)}
))}
{hasFilters && (
<button
type="button"
onClick={() => router.replace(basePath as any)}
onClick={() => {
formRef.current?.reset();
try { localStorage.removeItem(storageKey); } catch {}
router.replace(basePath as any);
}}
className="
inline-flex items-center justify-center
h-10 px-4
border border-input
text-sm font-medium
inline-flex items-center gap-1
h-8 px-2.5
text-xs font-medium
text-muted-foreground
bg-background
rounded-md
hover:bg-accent hover:text-accent-foreground
transition-colors duration-200
w-full sm:w-auto
"
>
Clear
<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="M6 18L18 6M6 6l12 12" />
</svg>
{t("common.clear")}
</button>
)}
</div>
</>
)}
</form>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button
onClick={handleLogout}
title="Se déconnecter"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
);
}

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "./ui";
import { useTranslation } from "../../lib/i18n/context";
interface MarkBookReadButtonProps {
bookId: string;
@@ -10,12 +11,13 @@ interface MarkBookReadButtonProps {
}
export function MarkBookReadButton({ bookId, currentStatus }: MarkBookReadButtonProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const router = useRouter();
const isRead = currentStatus === "read";
const targetStatus = isRead ? "unread" : "read";
const label = isRead ? "Marquer non lu" : "Marquer comme lu";
const label = isRead ? t("markRead.markUnread") : t("markRead.markAsRead");
const handleClick = async () => {
setLoading(true);

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "../../lib/i18n/context";
interface MarkSeriesReadButtonProps {
seriesName: string;
@@ -10,12 +11,13 @@ interface MarkSeriesReadButtonProps {
}
export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const router = useRouter();
const allRead = booksReadCount >= bookCount;
const targetStatus = allRead ? "unread" : "read";
const label = allRead ? "Marquer non lu" : "Tout marquer lu";
const label = allRead ? t("markRead.markUnread") : t("markRead.markAllRead");
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();

View File

@@ -6,23 +6,12 @@ import { useRouter } from "next/navigation";
import { Icon } from "./ui";
import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon";
import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context";
const FIELD_LABELS: Record<string, string> = {
description: "Description",
authors: "Auteurs",
publishers: "Éditeurs",
start_year: "Année",
total_volumes: "Nb volumes",
status: "Statut",
summary: "Résumé",
isbn: "ISBN",
publish_date: "Date de publication",
language: "Langue",
};
function fieldLabel(field: string): string {
return FIELD_LABELS[field] ?? field;
}
const FIELD_KEYS: string[] = [
"description", "authors", "publishers", "start_year",
"total_volumes", "status", "summary", "isbn", "publish_date", "language",
];
function formatValue(value: unknown): string {
if (value == null) return "—";
@@ -48,7 +37,15 @@ export function MetadataSearchModal({
existingLink,
initialMissing,
}: MetadataSearchModalProps) {
const { t } = useTranslation();
const router = useRouter();
const fieldLabel = (field: string): string => {
if (FIELD_KEYS.includes(field)) {
return t(`field.${field}` as any);
}
return field;
};
const [isOpen, setIsOpen] = useState(false);
const [step, setStep] = useState<ModalStep>("idle");
const [candidates, setCandidates] = useState<SeriesCandidateDto[]>([]);
@@ -62,6 +59,23 @@ export function MetadataSearchModal({
// Provider selector: empty string = library default
const [searchProvider, setSearchProvider] = useState("");
const [activeProvider, setActiveProvider] = useState("");
const [hiddenProviders, setHiddenProviders] = useState<Set<string>>(new Set());
// Fetch metadata provider settings to hide providers without required API keys
useEffect(() => {
fetch("/api/settings/metadata_providers")
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
const hidden = new Set<string>();
// ComicVine requires an API key
if (!data.comicvine?.api_key) hidden.add("comicvine");
setHiddenProviders(hidden);
})
.catch(() => {});
}, []);
const visibleProviders = PROVIDERS.filter((p) => !hiddenProviders.has(p.value));
const handleOpen = useCallback(() => {
setIsOpen(true);
@@ -109,7 +123,7 @@ export function MetadataSearchModal({
});
const data = await resp.json();
if (!resp.ok) {
setError(data.error || "Search failed");
setError(data.error || t("metadata.searchFailed"));
setStep("results");
return;
}
@@ -121,7 +135,7 @@ export function MetadataSearchModal({
}
setStep("results");
} catch {
setError("Network error");
setError(t("common.networkError"));
setStep("results");
}
}
@@ -160,7 +174,7 @@ export function MetadataSearchModal({
});
const matchData = await matchResp.json();
if (!matchResp.ok) {
setError(matchData.error || "Failed to create match");
setError(matchData.error || t("metadata.linkFailed"));
setStep("results");
return;
}
@@ -179,7 +193,7 @@ export function MetadataSearchModal({
});
const approveData = await approveResp.json();
if (!approveResp.ok) {
setError(approveData.error || "Failed to approve");
setError(approveData.error || t("metadata.approveFailed"));
setStep("results");
return;
}
@@ -201,7 +215,7 @@ export function MetadataSearchModal({
setStep("done");
} catch {
setError("Network error");
setError(t("common.networkError"));
setStep("results");
}
}
@@ -245,7 +259,7 @@ export function MetadataSearchModal({
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">
{step === "linked" ? "Metadata Link" : "Search External Metadata"}
{step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")}
</h3>
<button type="button" onClick={handleClose}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
@@ -258,9 +272,9 @@ export function MetadataSearchModal({
{/* Provider selector — visible during searching & results */}
{(step === "searching" || step === "results") && (
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground whitespace-nowrap">Provider :</label>
<label className="text-sm text-muted-foreground whitespace-nowrap">{t("metadata.provider")}</label>
<div className="flex gap-1 flex-wrap">
{PROVIDERS.map((p) => (
{visibleProviders.map((p) => (
<button
key={p.value}
type="button"
@@ -287,7 +301,7 @@ export function MetadataSearchModal({
{step === "searching" && (
<div className="flex items-center justify-center py-12">
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Searching for &quot;{seriesName}&quot;...</span>
<span className="ml-3 text-muted-foreground">{t("metadata.searching", { name: seriesName })}</span>
</div>
)}
@@ -302,13 +316,13 @@ export function MetadataSearchModal({
{step === "results" && (
<>
{candidates.length === 0 && !error ? (
<p className="text-muted-foreground text-center py-8">No results found.</p>
<p className="text-muted-foreground text-center py-8">{t("metadata.noResults")}</p>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground mb-2">
{candidates.length} result{candidates.length !== 1 ? "s" : ""} found
{t("metadata.resultCount", { count: candidates.length, plural: candidates.length !== 1 ? "s" : "" })}
{activeProvider && (
<span className="ml-1 text-xs inline-flex items-center gap-1">via <ProviderIcon provider={activeProvider} size={12} /> <span className="font-medium">{providerLabel(activeProvider)}</span></span>
<span className="ml-1 text-xs inline-flex items-center gap-1">{t("common.via")} <ProviderIcon provider={activeProvider} size={12} /> <span className="font-medium">{providerLabel(activeProvider)}</span></span>
)}
</p>
{candidates.map((c, i) => (
@@ -345,7 +359,7 @@ export function MetadataSearchModal({
</span>
)}
{c.metadata_json?.status === "RELEASING" && (
<span className="italic text-amber-500">en cours</span>
<span className="italic text-amber-500">{t("metadata.inProgress")}</span>
)}
</div>
</div>
@@ -376,18 +390,18 @@ export function MetadataSearchModal({
)}
{selectedCandidate.total_volumes != null && (
<p className="text-sm text-muted-foreground">
{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? "chapitres" : "volumes"}
{selectedCandidate.metadata_json?.status === "RELEASING" && <span className="italic text-amber-500 ml-1">(en cours)</span>}
{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? t("metadata.chapters") : t("metadata.volumes")}
{selectedCandidate.metadata_json?.status === "RELEASING" && <span className="italic text-amber-500 ml-1">({t("metadata.inProgress")})</span>}
</p>
)}
<p className="text-xs text-muted-foreground mt-1 inline-flex items-center gap-1">
via <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
{t("common.via")} <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
</p>
</div>
</div>
</div>
<p className="text-sm text-foreground font-medium">How would you like to sync?</p>
<p className="text-sm text-foreground font-medium">{t("metadata.howToSync")}</p>
<div className="flex flex-col gap-2">
<button
@@ -395,16 +409,16 @@ export function MetadataSearchModal({
onClick={() => handleApprove(true, false)}
className="w-full p-3 rounded-lg border border-border bg-card text-left hover:bg-muted/40 hover:border-primary/50 transition-colors"
>
<p className="font-medium text-sm text-foreground">Sync series metadata only</p>
<p className="text-xs text-muted-foreground">Update description, authors, publishers, and year</p>
<p className="font-medium text-sm text-foreground">{t("metadata.syncSeriesOnly")}</p>
<p className="text-xs text-muted-foreground">{t("metadata.syncSeriesOnlyDesc")}</p>
</button>
<button
type="button"
onClick={() => handleApprove(true, true)}
className="w-full p-3 rounded-lg border border-primary/50 bg-primary/5 text-left hover:bg-primary/10 transition-colors"
>
<p className="font-medium text-sm text-foreground">Sync series + books</p>
<p className="text-xs text-muted-foreground">Also fetch book list and show missing volumes</p>
<p className="font-medium text-sm text-foreground">{t("metadata.syncSeriesAndBooks")}</p>
<p className="text-xs text-muted-foreground">{t("metadata.syncSeriesAndBooksDesc")}</p>
</button>
</div>
@@ -413,7 +427,7 @@ export function MetadataSearchModal({
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
className="text-sm text-muted-foreground hover:text-foreground"
>
Back to results
{t("metadata.backToResults")}
</button>
</div>
)}
@@ -422,7 +436,7 @@ export function MetadataSearchModal({
{step === "syncing" && (
<div className="flex items-center justify-center py-12">
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Syncing metadata...</span>
<span className="ml-3 text-muted-foreground">{t("metadata.syncingMetadata")}</span>
</div>
)}
@@ -430,7 +444,7 @@ export function MetadataSearchModal({
{step === "done" && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<p className="font-medium text-green-600">Metadata synced successfully!</p>
<p className="font-medium text-green-600">{t("metadata.syncSuccess")}</p>
</div>
{/* Sync Report */}
@@ -439,7 +453,7 @@ export function MetadataSearchModal({
{/* Series report */}
{syncReport.series && (syncReport.series.fields_updated.length > 0 || syncReport.series.fields_skipped.length > 0) && (
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">Série</p>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">{t("metadata.seriesLabel")}</p>
{syncReport.series.fields_updated.length > 0 && (
<div className="space-y-1">
{syncReport.series.fields_updated.map((f, i) => (
@@ -461,7 +475,7 @@ export function MetadataSearchModal({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="font-medium">{fieldLabel(f.field)}</span>
<span className="text-muted-foreground">locked</span>
<span className="text-muted-foreground">{t("metadata.locked")}</span>
</div>
))}
</div>
@@ -480,7 +494,7 @@ export function MetadataSearchModal({
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Livres {syncReport.books_matched} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`}
{t("metadata.booksLabel")} {t("metadata.booksMatched", { matched: syncReport.books_matched, plural: syncReport.books_matched !== 1 ? "s" : "" })}{syncReport.books_unmatched > 0 && `, ${t("metadata.booksUnmatched", { count: syncReport.books_unmatched, plural: syncReport.books_unmatched !== 1 ? "s" : "" })}`}
</p>
{syncReport.books.length > 0 && (
<div className="space-y-2 max-h-48 overflow-y-auto">
@@ -503,7 +517,7 @@ export function MetadataSearchModal({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="font-medium">{fieldLabel(f.field)}</span>
<span className="text-muted-foreground">locked</span>
<span className="text-muted-foreground">{t("metadata.locked")}</span>
</p>
))}
</div>
@@ -521,15 +535,15 @@ export function MetadataSearchModal({
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">External</p>
<p className="text-sm text-muted-foreground">{t("metadata.external")}</p>
<p className="text-2xl font-semibold">{missing.total_external}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Local</p>
<p className="text-sm text-muted-foreground">{t("metadata.local")}</p>
<p className="text-2xl font-semibold">{missing.total_local}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Missing</p>
<p className="text-sm text-muted-foreground">{t("metadata.missingLabel")}</p>
<p className="text-2xl font-semibold text-warning">{missing.missing_count}</p>
</div>
</div>
@@ -542,14 +556,14 @@ export function MetadataSearchModal({
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
{missing.missing_count} missing book{missing.missing_count !== 1 ? "s" : ""}
{t("metadata.missingBooks", { count: missing.missing_count, plural: missing.missing_count !== 1 ? "s" : "" })}
</button>
{showMissingList && (
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
{missing.missing_books.map((b, i) => (
<p key={i} className="text-muted-foreground truncate">
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
{b.title || "Unknown"}
{b.title || t("metadata.unknown")}
</p>
))}
</div>
@@ -564,7 +578,7 @@ export function MetadataSearchModal({
onClick={() => { handleClose(); router.refresh(); }}
className="w-full p-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 transition-colors"
>
Close
{t("common.close")}
</button>
</div>
)}
@@ -576,7 +590,7 @@ export function MetadataSearchModal({
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground inline-flex items-center gap-1.5">
Linked to <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
{t("metadata.linkedTo")} <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
</p>
{existingLink.external_url && (
<a
@@ -585,7 +599,7 @@ export function MetadataSearchModal({
rel="noopener noreferrer"
className="block mt-1 text-xs text-primary hover:underline"
>
View on external source
{t("metadata.viewExternal")}
</a>
)}
</div>
@@ -596,15 +610,15 @@ export function MetadataSearchModal({
{initialMissing && (
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">External</p>
<p className="text-sm text-muted-foreground">{t("metadata.external")}</p>
<p className="text-2xl font-semibold">{initialMissing.total_external}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Local</p>
<p className="text-sm text-muted-foreground">{t("metadata.local")}</p>
<p className="text-2xl font-semibold">{initialMissing.total_local}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Missing</p>
<p className="text-sm text-muted-foreground">{t("metadata.missingLabel")}</p>
<p className="text-2xl font-semibold text-warning">{initialMissing.missing_count}</p>
</div>
</div>
@@ -618,14 +632,14 @@ export function MetadataSearchModal({
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
{initialMissing.missing_count} missing book{initialMissing.missing_count !== 1 ? "s" : ""}
{t("metadata.missingBooks", { count: initialMissing.missing_count, plural: initialMissing.missing_count !== 1 ? "s" : "" })}
</button>
{showMissingList && (
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
{initialMissing.missing_books.map((b, i) => (
<p key={i} className="text-muted-foreground truncate">
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
{b.title || "Unknown"}
{b.title || t("metadata.unknown")}
</p>
))}
</div>
@@ -639,14 +653,14 @@ export function MetadataSearchModal({
onClick={() => { doSearch(""); }}
className="flex-1 p-2.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
Search again
{t("metadata.searchAgain")}
</button>
<button
type="button"
onClick={handleUnlink}
className="p-2.5 rounded-lg border border-destructive/30 bg-destructive/5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
>
Unlink
{t("metadata.unlink")}
</button>
</div>
</div>
@@ -666,16 +680,9 @@ export function MetadataSearchModal({
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<Icon name="search" size="sm" />
{existingLink && existingLink.status === "approved" ? "Metadata" : "Search metadata"}
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
</button>
{/* Inline badge when linked */}
{existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 text-xs border border-yellow-500/30">
{initialMissing.missing_count} missing
</span>
)}
{existingLink && existingLink.status === "approved" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
<ProviderIcon provider={existingLink.provider} size={12} />

View File

@@ -4,11 +4,12 @@ import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { NavIcon } from "./ui";
import { useTranslation } from "../../lib/i18n/context";
type NavItem = {
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
label: string;
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
};
const HamburgerIcon = () => (
@@ -24,6 +25,7 @@ const XIcon = () => (
);
export function MobileNav({ navItems }: { navItems: NavItem[] }) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
@@ -53,7 +55,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
`}
>
<div className="h-16 border-b border-border/40 flex items-center px-4">
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">Navigation</span>
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">{t("nav.navigation")}</span>
</div>
<nav className="flex flex-col gap-1 p-3 flex-1">
@@ -76,7 +78,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
onClick={() => setIsOpen(false)}
>
<NavIcon name="settings" />
<span className="font-medium">Settings</span>
<span className="font-medium">{t("nav.settings")}</span>
</Link>
</div>
</nav>
@@ -90,7 +92,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
<button
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "Close menu" : "Open menu"}
aria-label={isOpen ? t("nav.closeMenu") : t("nav.openMenu")}
aria-expanded={isOpen}
>
{isOpen ? <XIcon /> : <HamburgerIcon />}

View File

@@ -1,6 +1,7 @@
"use client";
import { useTransition } from "react";
import { useTranslation } from "../../lib/i18n/context";
interface MonitoringFormProps {
libraryId: string;
@@ -10,6 +11,7 @@ interface MonitoringFormProps {
}
export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEnabled }: MonitoringFormProps) {
const { t } = useTranslation();
const [isPending, startTransition] = useTransition();
const handleSubmit = (formData: FormData) => {
@@ -51,7 +53,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
disabled={isPending}
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary"
/>
<span>Auto</span>
<span>{t("monitoring.auto")}</span>
</label>
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
@@ -67,7 +69,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
disabled={isPending}
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
/>
<span title="Real-time file watcher"></span>
<span title={t("monitoring.fileWatch")}></span>
</label>
<select
@@ -76,10 +78,10 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
disabled={isPending}
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="manual">{t("monitoring.manual")}</option>
<option value="hourly">{t("monitoring.hourly")}</option>
<option value="daily">{t("monitoring.daily")}</option>
<option value="weekly">{t("monitoring.weekly")}</option>
</select>
<button

View File

@@ -0,0 +1,47 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type Period = "day" | "week" | "month";
export function PeriodToggle({
labels,
}: {
labels: { day: string; week: string; month: string };
}) {
const router = useRouter();
const searchParams = useSearchParams();
const raw = searchParams.get("period");
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
function setPeriod(period: Period) {
const params = new URLSearchParams(searchParams.toString());
if (period === "month") {
params.delete("period");
} else {
params.set("period", period);
}
const qs = params.toString();
router.push(qs ? `?${qs}` : "/", { scroll: false });
}
const options: Period[] = ["day", "week", "month"];
return (
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
{options.map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
current === p
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{labels[p]}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,383 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Icon } from "./ui";
import type { ProwlarrRelease, ProwlarrSearchResponse } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context";
interface MissingBookItem {
title: string | null;
volume_number: number | null;
external_book_id: string | null;
}
interface ProwlarrSearchModalProps {
seriesName: string;
missingBooks: MissingBookItem[] | null;
}
function formatSize(bytes: number): string {
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB";
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB";
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + " KB";
return bytes + " B";
}
export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearchModalProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [isConfigured, setIsConfigured] = useState<boolean | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [results, setResults] = useState<ProwlarrRelease[]>([]);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
// qBittorrent state
const [isQbConfigured, setIsQbConfigured] = useState(false);
const [sendingGuid, setSendingGuid] = useState<string | null>(null);
const [sentGuids, setSentGuids] = useState<Set<string>>(new Set());
const [sendError, setSendError] = useState<string | null>(null);
// Check if Prowlarr and qBittorrent are configured on mount
useEffect(() => {
fetch("/api/settings/prowlarr")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
setIsConfigured(!!(data && data.api_key && data.api_key.trim()));
})
.catch(() => setIsConfigured(false));
fetch("/api/settings/qbittorrent")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
setIsQbConfigured(!!(data && data.url && data.url.trim() && data.username && data.username.trim()));
})
.catch(() => setIsQbConfigured(false));
}, []);
const [searchInput, setSearchInput] = useState(`"${seriesName}"`);
const doSearch = useCallback(async (queryOverride?: string) => {
const searchQuery = queryOverride ?? searchInput;
if (!searchQuery.trim()) return;
setIsSearching(true);
setError(null);
setResults([]);
try {
const missing_volumes = missingBooks?.map((b) => ({
volume_number: b.volume_number,
title: b.title,
})) ?? undefined;
const body = { series_name: seriesName, custom_query: searchQuery.trim(), missing_volumes };
const resp = await fetch("/api/prowlarr/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.error) {
setError(data.error);
} else {
const searchResp = data as ProwlarrSearchResponse;
setResults(searchResp.results);
setQuery(searchResp.query);
}
} catch {
setError(t("prowlarr.searchError"));
} finally {
setIsSearching(false);
}
}, [t, seriesName, searchInput]);
const defaultQuery = `"${seriesName}"`;
function handleOpen() {
setIsOpen(true);
setResults([]);
setError(null);
setQuery("");
setSearchInput(defaultQuery);
// Auto-search the series on open
doSearch(defaultQuery);
}
function handleClose() {
setIsOpen(false);
}
async function handleSendToQbittorrent(downloadUrl: string, guid: string) {
setSendingGuid(guid);
setSendError(null);
try {
const resp = await fetch("/api/qbittorrent/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: downloadUrl }),
});
const data = await resp.json();
if (data.error) {
setSendError(data.error);
} else if (data.success) {
setSentGuids((prev) => new Set(prev).add(guid));
} else {
setSendError(data.message || t("prowlarr.sentError"));
}
} catch {
setSendError(t("prowlarr.sentError"));
} finally {
setSendingGuid(null);
}
}
// Don't render button if not configured
if (isConfigured === false) return null;
if (isConfigured === null) return null;
const modal = isOpen
? createPortal(
<>
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={handleClose}
/>
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">{t("prowlarr.modalTitle")}</h3>
<button type="button" onClick={handleClose}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="p-5 space-y-4">
{/* Search input */}
<form
onSubmit={(e) => {
e.preventDefault();
if (searchInput.trim()) doSearch(searchInput.trim());
}}
className="flex items-center gap-2"
>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
placeholder={t("prowlarr.searchPlaceholder")}
/>
<button
type="submit"
disabled={isSearching || !searchInput.trim()}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Icon name="search" size="sm" />
{t("prowlarr.searchAction")}
</button>
</form>
{/* Quick search badges */}
<div className="flex flex-wrap items-center gap-2 max-h-24 overflow-y-auto">
<button
type="button"
onClick={() => { setSearchInput(defaultQuery); doSearch(defaultQuery); }}
disabled={isSearching}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-50 transition-colors"
>
{seriesName}
</button>
{missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => {
const label = book.title || `Vol. ${book.volume_number}`;
const q = book.volume_number != null ? `"${seriesName}" ${book.volume_number}` : `"${seriesName}" ${label}`;
return (
<button
key={i}
type="button"
onClick={() => { setSearchInput(q); doSearch(q); }}
disabled={isSearching}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border bg-muted/30 hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
{label}
</button>
);
})}
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Searching indicator */}
{isSearching && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Icon name="spinner" size="sm" className="animate-spin" />
{t("prowlarr.searching")}
</div>
)}
{/* Results */}
{!isSearching && results.length > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-3">
{t("prowlarr.resultCount", { count: results.length, plural: results.length !== 1 ? "s" : "" })}
{query && <span className="ml-1 text-xs opacity-70">({query})</span>}
</p>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-left">
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnTitle")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnIndexer")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground text-right">{t("prowlarr.columnSize")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnSeeders")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnLeechers")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnProtocol")}</th>
<th className="px-3 py-2 font-medium text-muted-foreground text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{results.map((release, i) => {
const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0;
return (
<tr key={release.guid || i} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}>
<td className="px-3 py-2 max-w-[400px]">
<span className="truncate block" title={release.title}>
{release.title}
</span>
{hasMissing && (
<div className="flex items-center gap-1 mt-1">
{release.matchedMissingVolumes!.map((vol) => (
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
{t("prowlarr.missingVol", { vol })}
</span>
))}
</div>
)}
</td>
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{release.indexer || "—"}
</td>
<td className="px-3 py-2 text-right text-muted-foreground whitespace-nowrap">
{release.size > 0 ? formatSize(release.size) : "—"}
</td>
<td className="px-3 py-2 text-center">
{release.seeders != null ? (
<span className={release.seeders > 0 ? "text-green-500 font-medium" : "text-muted-foreground"}>
{release.seeders}
</span>
) : "—"}
</td>
<td className="px-3 py-2 text-center text-muted-foreground">
{release.leechers != null ? release.leechers : "—"}
</td>
<td className="px-3 py-2">
{release.protocol && (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
release.protocol === "torrent"
? "bg-blue-500/15 text-blue-600"
: "bg-amber-500/15 text-amber-600"
}`}>
{release.protocol}
</span>
)}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1.5">
{isQbConfigured && release.downloadUrl && (
<button
type="button"
onClick={() => handleSendToQbittorrent(release.downloadUrl!, release.guid)}
disabled={sendingGuid === release.guid || sentGuids.has(release.guid)}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 ${
sentGuids.has(release.guid)
? "text-green-500"
: "text-primary hover:bg-primary/10"
}`}
title={sentGuids.has(release.guid) ? t("prowlarr.sentSuccess") : t("prowlarr.sendToQbittorrent")}
>
{sendingGuid === release.guid ? (
<Icon name="spinner" size="sm" className="animate-spin" />
) : sentGuids.has(release.guid) ? (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8l4 4 6-7" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 8V14H2V2H8M10 2H14V6M14 2L7 9" />
</svg>
)}
</button>
)}
{release.downloadUrl && (
<a
href={release.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-primary hover:bg-primary/10 transition-colors"
title={t("prowlarr.download")}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2v8M4 7l4 4 4-4M2 13h12" />
</svg>
</a>
)}
{release.infoUrl && (
<a
href={release.infoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:bg-muted/50 transition-colors"
title={t("prowlarr.info")}
>
<Icon name="externalLink" size="sm" />
</a>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* qBittorrent send error */}
{sendError && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{sendError}
</div>
)}
{/* No results */}
{!isSearching && !error && query && results.length === 0 && (
<p className="text-sm text-muted-foreground">{t("prowlarr.noResults")}</p>
)}
</div>
</div>
</div>
</>,
document.body,
)
: null;
return (
<>
<button
type="button"
onClick={handleOpen}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors"
>
<Icon name="search" size="sm" />
{t("prowlarr.searchButton")}
</button>
{modal}
</>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import type { CurrentlyReadingItem, RecentlyReadItem } from "@/lib/api";
import { getBookCoverUrl } from "@/lib/api";
function FilterPills({ usernames, selected, allLabel, onSelect }: {
usernames: string[];
selected: string | null;
allLabel: string;
onSelect: (u: string | null) => void;
}) {
if (usernames.length <= 1) return null;
return (
<div className="flex flex-wrap gap-1.5 mb-3">
<button
onClick={() => onSelect(null)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selected === null
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{allLabel}
</button>
{usernames.map((u) => (
<button
key={u}
onClick={() => onSelect(u === selected ? null : u)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selected === u
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{u}
</button>
))}
</div>
);
}
export function CurrentlyReadingList({
items,
allLabel,
emptyLabel,
pageProgressTemplate,
}: {
items: CurrentlyReadingItem[];
allLabel: string;
emptyLabel: string;
/** Template with {{current}} and {{total}} placeholders */
pageProgressTemplate: string;
}) {
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
const [selected, setSelected] = useState<string | null>(null);
const filtered = selected ? items.filter((i) => i.username === selected) : items;
return (
<div>
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{filtered.slice(0, 8).map((book) => {
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
return (
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
{book.username && usernames.length > 1 && (
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{pageProgressTemplate.replace("{{current}}", String(book.current_page)).replace("{{total}}", String(book.page_count))}</p>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
export function RecentlyReadList({
items,
allLabel,
emptyLabel,
}: {
items: RecentlyReadItem[];
allLabel: string;
emptyLabel: string;
}) {
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
const [selected, setSelected] = useState<string | null>(null);
const filtered = selected ? items.filter((i) => i.username === selected) : items;
return (
<div>
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{filtered.map((book) => (
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
{book.username && usernames.length > 1 && (
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { useTranslation } from "../../lib/i18n/context";
interface SeriesFiltersProps {
basePath: string;
currentSeriesStatus?: string;
currentHasMissing: boolean;
seriesStatusOptions: { value: string; label: string }[];
}
export function SeriesFilters({ basePath, currentSeriesStatus, currentHasMissing, seriesStatusOptions }: SeriesFiltersProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const updateFilter = useCallback((key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
params.delete("page");
const qs = params.toString();
router.push(`${basePath}${qs ? `?${qs}` : ""}` as any);
}, [router, searchParams, basePath]);
return (
<div className="flex flex-wrap gap-3">
<select
value={currentSeriesStatus || ""}
onChange={(e) => updateFilter("series_status", e.target.value)}
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
>
{seriesStatusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<select
value={currentHasMissing ? "true" : ""}
onChange={(e) => updateFilter("has_missing", e.target.value)}
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
>
<option value="">{t("seriesFilters.all")}</option>
<option value="true">{t("seriesFilters.missingBooks")}</option>
</select>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useOptimistic, useTransition } from "react";
interface TokenUserSelectProps {
tokenId: string;
currentUserId?: string;
users: { id: string; username: string }[];
action: (formData: FormData) => Promise<void>;
noUserLabel: string;
}
export function TokenUserSelect({ tokenId, currentUserId, users, action, noUserLabel }: TokenUserSelectProps) {
const [optimisticValue, setOptimisticValue] = useOptimistic(currentUserId ?? "");
const [, startTransition] = useTransition();
return (
<select
value={optimisticValue}
onChange={(e) => {
const newValue = e.target.value;
startTransition(async () => {
setOptimisticValue(newValue);
const fd = new FormData();
fd.append("id", tokenId);
fd.append("user_id", newValue);
await action(fd);
});
}}
className="flex h-8 rounded-md border border-input bg-background px-2 py-0 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">{noUserLabel}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
);
}

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