Compare commits

...

136 Commits

Author SHA1 Message Date
9f1ad4f5dd Merge pull request 'manual-cleanup' (#37) from manual-cleanup into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m50s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/37
2026-04-26 21:08:11 +00:00
Garret Patti
e283d03e95 update db pragma 2026-04-26 17:07:20 -04:00
Garret Patti
0e600e5f6c search mixed few text 2026-04-21 17:57:52 -04:00
Garret Patti
2cf8bc6d7d search-fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 56s
2026-04-21 14:55:28 -04:00
da3ad97d51 Merge pull request 'ratings' (#36) from ratings into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/36
2026-04-21 15:18:00 +00:00
Garret Patti
b5d144c8cc add ratings to doom scroll 2026-04-21 11:17:43 -04:00
Garret Patti
d854bbe99b add rating system 2026-04-21 10:57:08 -04:00
d2057fb81c Merge pull request 'comic library improvements' (#35) from comic-improv into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/35
2026-04-21 01:42:43 +00:00
Garret Patti
27430dbf52 comic library improvements 2026-04-20 21:42:23 -04:00
Garret Patti
bd028a7a5d scan fixes
All checks were successful
Build and Push Docker Image / build (push) Successful in 56s
2026-04-20 20:31:18 -04:00
Garret Patti
8f8f8c3001 mapping-tweaks
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m8s
2026-04-20 19:56:12 -04:00
Garret Patti
dee9356004 trash corrupt files
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
2026-04-20 11:44:30 -04:00
7d2ae7e95c Merge pull request 'fix blocking during scans' (#34) from large-library-fix into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m4s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/34
2026-04-20 13:12:56 +00:00
Garret Patti
cedc012733 fix blocking during scans 2026-04-20 09:11:14 -04:00
a9461f9ae4 Merge pull request 'don't block during scan' (#33) from large-library-fix into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 56s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/33
2026-04-20 12:29:51 +00:00
Garret Patti
a6d657d87d don't block during scan 2026-04-20 08:28:43 -04:00
Garret Patti
71a026f01e handle merging tag categories
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
2026-04-19 23:25:09 -04:00
fc9a7af7c3 Merge pull request 'import-comicinfoxml' (#32) from import-comicinfoxml into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/32
2026-04-20 03:09:44 +00:00
Garret Patti
b12decc802 search existing tags for default 2026-04-19 23:09:28 -04:00
Garret Patti
6c6a35433c tag mapping improvements 2026-04-19 23:00:10 -04:00
Garret Patti
0842769125 add tag imports 2026-04-19 21:41:34 -04:00
95bcaf53be Merge pull request 'add manga library' (#31) from manga-comic-library into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m8s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/31
2026-04-20 00:25:51 +00:00
Garret Patti
b0e9c9790c add manga library 2026-04-19 20:25:06 -04:00
Garret Patti
fbcd592609 Use game cover as series cover if series cover is not available
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
2026-04-18 12:44:01 -04:00
7b76e3d900 Merge pull request 'maintainability' (#30) from maintainability into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/30
2026-04-18 15:55:54 +00:00
Garret Patti
2ea02b197b expand user permissions 2026-04-18 11:48:01 -04:00
Garret Patti
8f84da7e2f add keyboard navigation 2026-04-18 11:18:40 -04:00
Garret Patti
625e256944 reduce repeated tag selector code 2026-04-18 11:10:26 -04:00
152bc12427 Merge pull request 'more-ui-adjustments' (#29) from more-ui-adjustments into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 58s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/29
2026-04-18 04:38:33 +00:00
Garret Patti
345a05e42a fix TV show metadata refresh 2026-04-18 00:38:04 -04:00
Garret Patti
0de839393a fix tv navigation 2026-04-18 00:22:02 -04:00
Garret Patti
0ff3ed8ac9 add gameview series navigation 2026-04-18 00:14:18 -04:00
Garret Patti
b2e9df8ab8 add gameview navigation 2026-04-17 23:55:33 -04:00
b774cba046 Merge pull request 'consistent-ui-across-libraries' (#28) from consistent-ui-across-libraries into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/28
2026-04-15 12:32:04 +00:00
Garret Patti
5b5c3453d2 add download buttons to tv 2026-04-15 08:30:41 -04:00
Garret Patti
37dcb79546 fix tv view 2026-04-15 08:16:38 -04:00
c2135747b5 Merge pull request 'image-viewer-improvements' (#27) from image-viewer-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/27
2026-04-14 23:56:16 +00:00
Garret Patti
afcf740f63 update ai buttons 2026-04-14 19:55:44 -04:00
Garret Patti
dae33a36bc remember tag selector state 2026-04-14 19:17:22 -04:00
Garret Patti
a379e94bce media viewer consistency 2026-04-14 18:45:06 -04:00
Garret Patti
0b03b937e0 update dockerfile
All checks were successful
Build and Push Docker Image / build (push) Successful in 54s
2026-04-14 08:31:30 -04:00
Garret Patti
19756c9eab docker fixes
All checks were successful
Build and Push Docker Image / build (push) Successful in 56s
2026-04-14 08:25:12 -04:00
b25774d928 Merge pull request 'responsiveness' (#26) from responsiveness into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 54s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/26
2026-04-14 02:14:02 +00:00
Garret Patti
db2e446ef4 feat: per-extraction OCR language override
Allow users to specify a Tesseract language string (e.g. jpn+jpn_vert)
on a per-extraction basis, overriding the global OCR language setting.

- Add payload column to ai_jobs table (migration) to carry per-call data
- Thread ocrLanguages payload through enqueueJob → processNextJob → extractItemText
- New GET /api/ai-settings/ocr endpoint (requireAuth) returns { ocrMode, ocrLanguages }
- ImageLightbox fetches OCR settings and shows a language input next to the
  Extract Text button when mode is hybrid or tesseract (hidden for llm-only)
- MixedView fetches OCR settings and passes them down to EntryTile; kebab
  Extract Text on images shows an inline language prompt before dispatching the job

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:55:07 -04:00
Garret Patti
96cfb8aae7 UI polish: live job polling, panel layout, pending button states
- Poll /api/ai-tagging/fields every 2s after any 202 (queued) response in
  ImageLightbox and DoomScrollView so extraction, translation, and description
  results appear automatically without a page refresh
- DoomScrollView extract button now turns accent-coloured while a job is
  queued instead of flashing red; red is reserved for genuine errors
- Kebab menu "Translate" option is now gated on entry.hasExtractedText
  (populated via a batch DB query in the browse API) so it only appears
  when there is text to translate
- Tag panel redesigned: toolbar collapses to just the filename when open;
  panel header holds hide (›), AI Tagger (), and Close (✕) buttons;
  sections ordered Description → Text Extraction → Tags; description
  state and generate handler moved from TagSelector into ImageLightbox
- VideoPlayerModal receives the same toolbar/panel restructure
- TagSelector gains hideDescription prop so the parent can own description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:37:20 -04:00
Garret Patti
d754f85717 update gitignore 2026-04-13 19:45:20 -04:00
9d73459f48 Merge pull request 'customize-context-length' (#25) from customize-context-length into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m7s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/25
2026-04-13 23:41:09 +00:00
Garret Patti
9b2690f639 add tesseract ocr 2026-04-13 19:40:25 -04:00
Garret Patti
1350a6f94b separate text extraction and translation 2026-04-13 17:45:00 -04:00
Garret Patti
2fc9a34626 add configurable max_tokens per AI activity
Allows users to configure the max_tokens sent to the AI endpoint for
each activity (tagging, description, extraction, translation) individually,
with per-library overrides following the same pattern as model overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:57:07 -04:00
236f168eeb Merge pull request 'text-extraction-improvements' (#24) from text-extraction-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/24
2026-04-13 16:29:25 +00:00
Garret Patti
fea55594d0 add ai job queue 2026-04-13 12:29:09 -04:00
Garret Patti
8557c80c52 reduce api calls for text extraction 2026-04-13 11:18:39 -04:00
Garret Patti
68b1ed94ea fix vertical image clipping in viewer 2026-04-13 10:53:05 -04:00
Garret Patti
e31a9667ef text extraction improvements: editable text and source language hint
- Extracted text in the tag panel is now an editable textarea; a Save
  button appears when the content is dirty and persists edits to the DB
- Source language input added next to Re-translate button; when filled,
  the translation prompt uses "translate from X to Y" for more accurate
  results
- New updateExtractedText() helper and PATCH /api/ai-tagging/fields
  endpoint to support saving edited text
- translateItemText/translateText accept optional sourceLanguage param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:29:47 -04:00
c454d020da Merge pull request 'doomscroll-improvements' (#23) from doomscroll-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 54s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/23
2026-04-13 13:23:33 +00:00
Garret Patti
b0fc275a52 add extract text button to doom scroll mode
Show an extract-text button (document icon) in the bottom bar when the
current image has no extracted text yet. Clicking it calls the extract-text
API, shows a spinner while in progress, and on success replaces itself with
the text-lines display button and auto-opens the overlay. Error state briefly
turns the button red. Resets on every item navigation alongside the other
text state. Hidden for videos and items without an itemKey.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:19:32 -04:00
Garret Patti
cd9a83ea90 send higher resolution images to AI vision endpoints
Add getAiImagePath() to thumbnails.ts (1920px wide, quality 90, no
upscaling) cached separately from display thumbnails via an _ai suffix.
Swap all four image-to-AI code paths in ai-tagger.ts (extract text,
describe, batch tagging x2) to use the new high-res image instead of
the 400px display thumbnail, improving OCR accuracy on dense text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:08:43 -04:00
Garret Patti
5ba73b2e56 doom scroll and viewer improvements
- move play/pause to clicking the video directly; remove dedicated button
- replace emoji mute icons with flat minimal SVGs
- add view-in-library button in doom scroll that navigates to the file's
  directory and opens it in the regular viewer
- add display text overlay button in doom scroll and image lightbox;
  shows extracted text (translated by default when available) in a
  semi-transparent box at the bottom; toggle between translated/original
- hide tag panel by default in image lightbox and video player modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 08:16:34 -04:00
2b51f72f96 Merge pull request 'ai-customization' (#22) from ai-customization into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/22
2026-04-13 01:13:41 +00:00
Garret Patti
efaff8ca1b add applied tags as context to description prompt
When generating an item description, any already-applied tags are
appended to the system prompt as a source of truth, so the model
can produce a more accurate description aligned with existing tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:12:58 -04:00
Garret Patti
89ac22e9d1 show applied tags first in tag selector picker
Applied tags are now pinned to the front of each category's tag list,
with unapplied tags continuing in usage order behind them. Both
partitions preserve the existing usage-sort from the API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:58:12 -04:00
Garret Patti
b0d146679f scope doom scroll to current directory when no filters active
When no filters are selected, doom scroll now recursively fetches only
items under the current directory instead of the entire library root.
Navigating to a new directory invalidates the cached listing. Filter-
based doom scroll (search or tags) continues to search library-wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:51:29 -04:00
Garret Patti
887cc05901 add per-library AI model and prompt customization
- Add library_ai_settings table with migration for per-library overrides
- Extend AiConfig with editable prompt parts for description, tagging,
  extraction, and translation steps; defaults match previous hardcoded values
- Add getEffectiveAiConfig(libraryId) that merges global settings with
  library-level overrides (empty override falls through to global)
- Update all ai-tagger functions to use getEffectiveAiConfig and build
  prompts from configurable parts
- Add GET/PUT /api/ai-settings/library/[id] for per-library overrides
- Update /api/ai-settings GET/PUT to include prompt fields
- Add Prompts section and Library Overrides section to admin UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:37:11 -04:00
afb9540df2 Merge pull request 'ai-descriptions' (#21) from ai-descriptions into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/21
2026-04-12 23:55:05 +00:00
Garret Patti
5ac4b3bd8a customize model based on step 2026-04-12 19:50:18 -04:00
Garret Patti
470f34c985 feed extracted text to image tagger prompt 2026-04-12 19:15:19 -04:00
Garret Patti
7e284383b4 add ai descriptions and extracted text 2026-04-12 18:18:59 -04:00
60790a3af1 Merge pull request 'ai-feature-setup' (#20) from ai-feature-setup into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/20
2026-04-12 21:24:57 +00:00
Garret Patti
6c769b457f handle video tagging 2026-04-12 17:24:39 -04:00
Garret Patti
ad9920a448 limit tags sent and send applied tags to ai 2026-04-12 16:45:26 -04:00
Garret Patti
732e9134c3 ai starter implementation 2026-04-12 15:39:48 -04:00
Garret Patti
0238dbda7a Add AI-powered image tagging via local LLM
Adds automatic image tagging that runs as a post-scan phase, sending
thumbnails to an OpenAI-compatible vision API and applying matching
tags from the user-defined tag vocabulary.

- New ai-tagger module with batch processing, failure tolerance, and
  tag validation against existing vocabulary
- Admin settings page (Manage > AI Tagging) for endpoint, model, and
  enable toggle with connection testing
- DB migration for ai_tagged_at tracking column and AI config seeds
- Re-tag All support to queue items for reprocessing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:18:03 -04:00
9bff0f848a Merge pull request 'add individual library scanning' (#19) from scanner-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 53s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/19
2026-04-12 18:10:13 +00:00
Garret Patti
aae41e9803 add individual library scanning 2026-04-12 13:51:51 -04:00
7e9ba6e014 Merge pull request 'add-android-platform' (#18) from add-android-platform into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/18
2026-04-12 17:09:29 +00:00
Garret Patti
0091606e4d handle other archive types for linux 2026-04-12 13:09:07 -04:00
Garret Patti
080cc011b9 icon color and size tweaks 2026-04-12 12:41:42 -04:00
Garret Patti
d3e1bf049b handle android and swap to os icons 2026-04-12 11:53:27 -04:00
625539f35e Merge pull request 'game-enhancements' (#17) from game-enhancements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m4s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/17
2026-04-12 14:19:51 +00:00
Garret Patti
84c65c7964 Add screenshots to game detail modal
- New /api/game-screenshots route handles GET (list), POST (upload), and DELETE (remove single file)
- Screenshots stored in a screenshots/ subfolder inside each game directory
- Images converted to JPEG via Sharp on upload, named shot-{timestamp}.jpg
- Modal shows a horizontal scrollable strip of 16:9 thumbnail tiles
- Hover a tile to reveal a delete button; uploading placeholders appear per in-flight upload
- Dashed + tile triggers multi-file picker for batch uploads
- Click any thumbnail to open a full-screen lightbox with prev/next arrows, counter, and keyboard nav (←/→/Esc)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:18:38 -04:00
Garret Patti
53205d4a19 Add multi-platform game support with per-OS download detection
- Detect Windows (.zip), Linux (.tar.gz), and macOS (.dmg / .app bundle) game archives during scan
- Store GameFile[] with platform metadata in DB instead of plain zipFiles[]
- Stream .app bundles as on-the-fly zip archives via archiver
- Show WIN/LIN/MAC platform badge pills on GameCard and SeriesCard
- Auto-select the download matching the user's OS in GameDetailModal
- Persist cover URL to DB immediately on upload (no re-scan needed)
- Backward-compatible: legacy zipFiles entries map to platform 'windows'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 09:47:09 -04:00
ebc35d7184 Merge pull request 'add more management capabilities' (#16) from management into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/16
2026-04-11 22:34:38 +00:00
Garret Patti
768c49ef00 add more management capabilities 2026-04-11 18:33:03 -04:00
1ca90184f5 Merge pull request 'clean-up' (#15) from clean-up into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m42s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/15
2026-04-11 01:33:10 +00:00
Garret Patti
6c2443fa2c Filter non-browser-playable formats from Doom Scroll
Formats like .mkv, .avi, .wmv, .ts, .m2ts and .tiff are not natively
supported by browsers and would stall silently in Doom Scroll mode.

Add src/lib/browser-media.ts with BROWSER_VIDEO_EXTENSIONS (.mp4,
.webm, .mov, .m4v), BROWSER_IMAGE_EXTENSIONS (.jpg, .jpeg, .png, .gif,
.webp, .bmp), and an isBrowserPlayable() helper that extracts the
extension without importing Node's path module.

Filter doomScrollItems in MixedView, MoviesView, and TvView using this
helper so only natively renderable files are passed to DoomScrollView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:29:17 -04:00
Garret Patti
5d4d11512d Fix DoomScrollView going blank after 100 items
When history hit the 100-entry cap, setHistory sliced the array back to
indices 0–99 but setHistoryIndex still returned idx + 1 = 100, making
current = history[100] = undefined. Nothing rendered and no API calls
were made until the user went back (decrementing to index 99, which
held the newly-picked item).

Fix: cap the returned historyIndex at HISTORY_CAP - 1 so it always
points to a valid entry in the sliced array. Extract HISTORY_CAP = 100
as a named constant so the slice and the index cap stay in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:13:06 -04:00
Garret Patti
6f86750a99 Unify media_key and item_key — use item_key everywhere
media_key was a lossy shortening of item_key (libraryId:lastSegment) that
introduced a real collision bug: two TV episodes from different series with
the same filename would share the same media_key and each other's tags.

- DB migration converts existing media_tags rows from short format to full
  item_key by joining against media_items; ambiguous/orphaned rows are dropped
- media_tags column renamed media_key → item_key
- Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes
  item_key directly to reKeyMediaItem
- DB reader functions (tv, movies, games) now expose item_key on returned
  entities; frontend components use entity.item_key instead of constructing
  the short libraryId:id form
- MixedView now constructs the full mixed_file: item_key format
- Tag API renamed mediaKey param → itemKey throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:04:29 -04:00
390ce8fcc6 Merge pull request 'performance-stability' (#14) from performance-stability into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/14
2026-04-07 00:22:00 +00:00
Garret Patti
f08950f456 Fix Doom Scroll mode bugs in TV libraries and video autoplay
TV library fix: the unfiltered Doom Scroll path was calling
/api/browse which explicitly rejects non-mixed libraries with a 400
error, leaving the item list empty. Replace it with the same TV API
hierarchy fetch already used by the filtered path (series → seasons →
episodes), matching how the rest of the TV library is loaded.

Autoplay fix (all library types): two interacting bugs caused videos
to silently stall on navigation. First, the play/pause effect had
current?.url in its deps, so navigating while paused would call
pause() on the freshly-mounted video element before the isPaused reset
could take effect. Second, browser autoplay policy blocks unmuted
play() calls and the rejection was silently swallowed with no recovery.
Fix by merging the isPaused reset and the play() call into one
navigation effect, and adding a muted fallback on rejection so playback
always starts — the user can unmute manually afterward.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:21:27 -04:00
Garret Patti
4d75e74cab Fix post-scan CPU spike and improve scan performance at scale
- Remove fire-and-forget thumbnail pre-warming from scanMixed(): firing
  48k+ simultaneous unresolved getThumbnailPath() promises was saturating
  sharp and ffmpeg after scan completion, keeping CPU pegged. Mixed-library
  thumbnails are now generated on-demand by /api/thumbnail as before.
- Add incremental fingerprinting: load existing (item_key → fingerprint)
  map from DB before each walk; reuse stored fingerprint for unchanged paths
  instead of re-reading 64 KB per file. Stable re-scans now do ~0 bytes of
  fingerprint I/O.
- Wrap all bulk DB upsert and delete loops in db.transaction() in
  scanMovies(), scanTv(), scanMixed(), and reconcileAndPrune(). Reduces
  N auto-committed WAL writes to a single batch commit per scan phase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:58:05 -04:00
e5953100d6 Merge pull request 'file-fingerprinting' (#13) from file-fingerprinting into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/13
2026-04-06 23:06:22 +00:00
Garret Patti
58c5e424d9 Fix media_tags not updating when fingerprint move is detected
reKeyMediaItem was called with item_key values (e.g. "lib1:movie:Inception")
but media_tags stores keys in the shorter UI format (e.g. "lib1:Inception"),
so the UPDATE never matched any rows.

Add itemKeyToMediaKey() to extract the terminal segment of an item_key and
reconstruct the media_key format the UI uses:
  lib1:movie:Inception%20(2010)          → lib1:Inception%20(2010)
  lib1:tv_episode:Show:Season1:ep.mkv   → lib1:ep.mkv
  lib1:mixed_file:dir%2Ffile.mp4        → lib1:dir%2Ffile.mp4

Also skip the UPDATE when old and new media_keys are identical (e.g. a TV
episode moved between seasons keeps the same filename-based media_key).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:59:51 -04:00
Garret Patti
38a6886863 Add file fingerprinting for move-resilient media item identity
Computes a SHA-256 partial-content fingerprint (file size + first 64 KB) for
movies, TV episodes, and mixed files during scans. When a file is moved or
renamed within a library, the scan detects the fingerprint match, renames the
media_items row in-place, and updates media_tags.media_key to match — so tags
and NFO metadata survive the move transparently.

- src/lib/fingerprint.ts: new computeFingerprint() using sync FS reads
- src/lib/db.ts: fingerprint TEXT column + index migration
- src/lib/tags.ts: reKeyMediaItem() to update media_tags on rename
- src/lib/scanner.ts: replace clear+upsert with detectMoves/reconcileAndPrune
  for movies, TV episodes, and mixed files; games retain clear+upsert (v1)
- TV scan restructured to a single filesystem pass (no double-scanning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:35:02 -04:00
Garret Patti
819748d1ff DB-first library reads, mixed library indexing, and manual NFO refresh
- API reads now serve from media_items cache instead of scanning the filesystem
  on every request; scans (manual or scheduled) remain the write path
- NFO metadata is no longer parsed automatically during scans; title falls back
  to folder/filename — metadata can be refreshed per-item via the kabob menu
- Mixed libraries are now indexed in media_items (new mixed_file item type)
  with file_path stored; scanMixed walks recursively and upserts all files
- Added file_path column to media_items and migrated item_type CHECK constraint
  to include mixed_file via safe table-recreation migration
- New POST /api/nfo-refresh endpoint reads the .nfo for a single item and
  patches its DB row (supports movie, tv_series, tv_episode)
- Added "Refresh metadata" button to movie and TV series kabob menus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:20:21 -04:00
01a4a1c0b7 Merge pull request 'fix search/filter bugs in game and TV libraries' (#12) from search-tweaks into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/12
2026-04-06 18:24:30 +00:00
Garret Patti
5d27ba351b fix search/filter bugs in game and TV libraries
- Game series: filter now checks child games for both search and tag matches instead of always passing series through
- TV episodes: tag selector no longer closes after picking a tag
- TV episodes: filter panel now filters episodes within a season view
- TV series list: series now appear when any of their episodes match the active tag filter (via new /api/tv/series-episode-tags endpoint backed by media_items)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:23:34 -04:00
957d884903 Merge pull request 'Reduce code duplication and update README' (#11) from cleanup-pass into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/11
2026-04-06 16:52:22 +00:00
Garret Patti
6b5ff81654 Reduce code duplication and update README
- Extract shared utilities (HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl,
  thumbnailApiUrl, findFile) into new src/lib/media-utils.ts, removing
  identical copies from games.ts, movies.ts, tv.ts, files.ts, and scanner.ts
- Add comment in files.ts clarifying why its VIDEO_EXTENSIONS set intentionally
  differs from the media library set (web-playable formats for the mixed browser)
- Rewrite README to reflect the current feature set: Movies/TV libraries, auth
  system, tag system, background scanner, updated project structure, folder
  conventions for all four library types, and a complete API reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:49:24 -04:00
80d922263e Merge pull request 'handle shows without season folders' (#10) from navigation into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/10
2026-04-06 02:18:58 +00:00
e46c8d026f Merge branch 'main' into navigation 2026-04-06 02:18:50 +00:00
Garret Patti
87a90a88bc handle shows without season folders 2026-04-05 22:18:08 -04:00
ffaa460324 Merge pull request 'navigation' (#9) from navigation into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/9
2026-04-06 01:48:01 +00:00
Garret Patti
0c234b691e add auto mode and controls 2026-04-05 21:47:44 -04:00
Garret Patti
4f54a7c888 add viewer navigation and doom scroll mode
- Add prev/next arrow buttons and ArrowLeft/ArrowRight keyboard shortcuts to ImageLightbox and VideoPlayerModal
- Wire prev/next navigation in MixedView (through filtered media entries), TvView (through season episodes), and MoviesView/MovieDetailModal (through filtered movie list)
- Add new DoomScrollView component: fullscreen random-media mode with scroll/swipe/keyboard navigation, 100-item back-history, and per-library mute settings
- Add Doom Scroll button to mixed, movies, and TV library views
- Doom scroll respects active filters: mixed uses filtered entries, movies uses filtered movie list, TV fetches episodes from matching series only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:34:32 -04:00
Garret Patti
334d62e3b3 fix docker auth
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m3s
2026-04-05 19:59:24 -04:00
612e20da8e Merge pull request 'library-scanning' (#8) from library-scanning into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m2s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/8
2026-04-05 23:24:49 +00:00
Garret Patti
6858c1e8cf add tagging to tv 2026-04-05 19:24:28 -04:00
Garret Patti
8829188c58 add scanning 2026-04-05 18:55:53 -04:00
c87a9b33bb Merge pull request 'login-user-settings' (#7) from login-user-settings into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/7
2026-04-05 22:17:53 +00:00
Garret Patti
5b5503b7a6 add user settings 2026-04-05 18:15:08 -04:00
Garret Patti
eecee9bc5f add auth 2026-04-05 17:44:24 -04:00
f0666c0649 Merge pull request 'viewer-improvements' (#6) from viewer-improvements into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 46s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/6
2026-04-05 20:32:17 +00:00
Garret Patti
ca4bea084a make FilterPanel hideable and responsive across all library views
Adds a toggle button to show/hide the filter panel in Movies, Games,
Mixed, and TV views. On mobile the layout stacks vertically (filter
above content); on md+ it returns to the side-by-side layout. The
toggle button highlights when filters are active so hidden filters
remain discoverable. Also fixes a layout bug where items-start on the
flex-col container caused MixedView thumbnails to collapse on narrow
screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:29:49 -04:00
Garret Patti
bc77abbd8b scaling tweaks 2026-04-05 16:03:51 -04:00
Garret Patti
427aade21a add tag selector to image and video viewers 2026-04-05 14:01:32 -04:00
2ba887dba6 Merge pull request 'Add Movie and TV Library types.' (#5) from library-types into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 59s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/5
2026-04-05 16:50:48 +00:00
Garret Patti
122d7aa332 add series grouping, cover upload, and multi-zip download to games library
- Series grouping: a top-level folder with no .zip but game subfolders is
  now treated as a GameSeries. Clicking a series drills into it with a
  breadcrumb; a game-count badge distinguishes series cards from game cards.
  Series fall back to the first game's cover when no series-level cover exists.
- Cover upload: new POST /api/game-cover endpoint writes cover.jpg or
  widecover.jpg directly into the game/series folder (re-encoded via sharp).
  A kebab menu on GameDetailModal opens an Edit Images panel showing previews
  and upload/replace buttons for both cover and wide cover.
- Multi-zip download: Game.zipFiles replaces zipPath and includes all .zip
  files in the folder. A single zip shows the existing download button; multiple
  zips render a split button — primary action downloads the first file, a
  dropdown arrow lists all files by name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:49:42 -04:00
Garret Patti
b254907cca put delete actions behind a kebab menu to prevent accidental deletion
Replace the always-visible delete buttons on the movie detail modal and
TV series header with a ⋮ kebab menu. Selecting "Delete" from the menu
shows an inline confirmation banner before any action is taken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:09:05 -04:00
Garret Patti
e8b317f99d add movies and tv show library types with Jellyfin NFO support
- Add `movies` type: per-movie folders with video files, poster/backdrop
  images, and optional Jellyfin NFO metadata (title, year, plot, rating,
  genres, runtime). Grid view with 2:3 poster art, detail modal with play
  and two-click delete of the movie folder.
- Add `tv` type: Series -> Season -> Episode hierarchy with lazy loading at
  each level. Reads tvshow.nfo and episodedetails NFO files for metadata.
  Episode grid with video thumbnails, streams via existing video player.
  Delete is limited to the entire series folder to avoid breaking Jellyfin.
- Add fast-xml-parser dependency for Kodi/Jellyfin NFO parsing (lib/nfo.ts)
- Migrate existing DB to expand the libraries CHECK constraint to include
  the two new types; migration is idempotent and preserves existing data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:36:05 -04:00
Garret Patti
b3abc7ee4c fix capitilization
All checks were successful
Build and Push Docker Image / build (push) Successful in 9s
2026-04-05 10:13:42 -04:00
Garret Patti
c3adf11e3e remove gitea references
Some checks failed
Build and Push Docker Image / build (push) Failing after 4s
2026-04-05 10:11:56 -04:00
Garret Patti
948b7390d3 change registry
Some checks failed
Build and Push Docker Image / build (push) Failing after 4s
2026-04-05 10:08:45 -04:00
Garret Patti
7560ad9094 update docker repo
Some checks failed
Build and Push Docker Image / build (push) Failing after 5s
2026-04-05 10:06:58 -04:00
Garret Patti
de8ba04bd3 bring up to date with github 2026-04-05 10:01:34 -04:00
1c3a0fe4ee Merge pull request 'claude/compassionate-feistel' (#4) from claude/compassionate-feistel into main
Some checks failed
Build and Push Docker Image / build (push) Failing after 57s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/4
2026-03-27 03:57:16 +00:00
4b7ab0d465 Merge branch 'main' into claude/compassionate-feistel 2026-03-27 03:57:02 +00:00
4d9cb537f3 add gitea docker build action 2026-03-26 23:52:18 -04:00
6ea04a5a68 Merge branch 'claude/compassionate-feistel' of http://gitea.lan/gpatti/MediaLore into claude/compassionate-feistel 2026-03-26 23:43:04 -04:00
e525b7faa3 add docker config 2026-03-26 23:41:52 -04:00
372df9c03c Merge pull request 'tagging' (#3) from tagging into main
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/3
2026-03-25 23:53:53 +00:00
3e2662f64c Merge pull request 'claude/compassionate-feistel' (#2) from claude/compassionate-feistel into tagging
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/2
2026-03-25 23:53:28 +00:00
1dcf89816d Merge branch 'tagging' into claude/compassionate-feistel 2026-03-25 23:53:18 +00:00
8f62fa1935 update .gitignore 2026-03-25 19:51:57 -04:00
43436f5cae add library filter panel and tag selector enhancements
- Add left sidebar filter panel to MixedView and GamesView with name
  search and tag toggles; only shows tags/categories used in the current
  library; AND logic when multiple tags are selected
- Add GET /api/tags/library-assignments endpoint returning all tag
  assignments for a library keyed by mediaKey
- Add getTagAssignmentsForLibrary() and getTagsSortedByUsage() to tags lib
- Support ?sort=usage on GET /api/tags/items to order by assignment count
- Tag selector: per-category search, top-25-by-usage display, inline add
  tag (auto-assigned to current item) and add category flows
- Tag selector: group assigned tags by category into nested pills
- Fix nested <button> hydration error in EntryTile (outer element is now
  a div with role="button")
- Keep filter panel assignments in sync when tags are toggled or created

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 19:50:28 -04:00
8ee8e1d2ab Merge pull request 'add tagging system for media items' (#1) from claude/compassionate-feistel into tagging
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/1
2026-03-25 22:18:45 +00:00
139 changed files with 22616 additions and 665 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
node_modules
.next
.thumbnails
medialore.db
medialore.db-shm
medialore.db-wal
data/
.env*
docker-compose*.yml

View File

@@ -0,0 +1,41 @@
name: Build and Push Docker Image
on:
push:
branches: [ main, dev ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Registry
uses: docker/login-action@v2
with:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build Docker Image
env:
BRANCH_NAME: ${{ github.ref_name }}
SHORT_HASH: ${{ github.sha }}
run: |
# Build the image with the commit hash tag
docker build --build-arg BUILD_IDENTIFIER=${SHORT_HASH:0:5} -t gpatti/medialoreweb-app:${BRANCH_NAME} .
# Tag the same image as "latest"
docker tag gpatti/medialoreweb-app:${BRANCH_NAME} gpatti/medialoreweb-app:${BRANCH_NAME}-latest
- name: Push Docker Images
env:
BRANCH_NAME: ${{ github.ref_name }}
SHORT_HASH: ${{ github.sha }}
run: |
docker push gpatti/medialoreweb-app:${BRANCH_NAME}
docker push gpatti/medialoreweb-app:${BRANCH_NAME}-latest
- name: Log out from registry
if: always()
run: docker logout gitea.lan

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ out/
medialore.db
medialore.db-shm
medialore.db-wal
tsconfig.tsbuildinfo
.session_secret
.vscode/
*.traineddata

1
.session_secret Normal file
View File

@@ -0,0 +1 @@
e63d0c98a24a2c5a25438b22b426d27e8ca9e3889e8d7a344b556ac48c5d51f3

59
Dockerfile Normal file
View File

@@ -0,0 +1,59 @@
# Stage 1: Install dependencies (with build tools for native modules)
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Build the Next.js application
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Production image
FROM node:22-bookworm-slim AS runner
WORKDIR /app
# Install ffmpeg for video thumbnail generation
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ENV PORT=3000
# Bind to all interfaces so the container port is reachable
ENV HOSTNAME=0.0.0.0
RUN mkdir -p /config
# Copy standalone Next.js output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# COPY --from=builder /app/public ./public
# Copy native modules — Next.js standalone's file tracer does not follow
# .node binary files, so we copy these manually from the deps stage.
# Both are listed in serverExternalPackages and resolved at runtime via require().
COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
COPY --from=deps /app/node_modules/sharp ./node_modules/sharp
COPY --from=deps /app/node_modules/@img ./node_modules/@img
# tesseract.js loads its worker via worker_threads using a runtime-constructed path,
# so the standalone file tracer never discovers src/worker-script/node/. Copy the
# full package so that path resolves correctly at runtime.
COPY --from=deps /app/node_modules/tesseract.js ./node_modules/tesseract.js
COPY --from=deps /app/node_modules/tesseract.js-core ./node_modules/tesseract.js-core
# Create thumbnail cache directory (mounted as a volume in production)
RUN mkdir -p /app/.thumbnails
EXPOSE 3000
CMD ["node", "server.js"]

223
README.md
View File

@@ -1,13 +1,18 @@
# MediaLore
A self-hosted web UI for browsing media libraries on a NAS or local filesystem. Configure folders as typed libraries — currently supporting **Games** and **Mixed Media** library types.
A self-hosted web UI for browsing media libraries on a NAS or local filesystem. Configure folders as typed libraries — supporting **Games**, **Movies**, **TV**, and **Mixed Media** library types.
## Features
- **Games library** — displays a grid of game cover art scanned from folders. Each game folder is expected to contain a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link for the zip.
- **Mixed media library** — a folder-navigable browser that mirrors the directory structure on disk. Image and video files display auto-generated square thumbnails. Videos open in an inline player (with full seek support via HTTP range requests). Images open in a lightbox. Other files are opened in a new tab.
- **Thumbnail generation** — lazy on-demand thumbnails for images (via `sharp`) and video frames (via `ffmpeg`). Thumbnails are cached to disk in `.thumbnails/` and regenerated automatically when the source file changes.
- **Library management UI** — add and remove libraries at `/manage` without touching any config files. Configuration persists across restarts in `libraries.json`.
- **Games library** — grid of game cover art scanned from folders. Each game folder contains a `.zip` archive and optional artwork (`cover.*`, `widecover.*`). Clicking a game opens a detail modal with a download link.
- **Movies library** — grid of movie posters scanned from per-movie folders. Reads `.nfo` sidecar files (Kodi-compatible) for metadata (title, year, plot, rating, genres). Clicking a movie opens a full-screen video player.
- **TV library** — browse TV series → seasons → episodes. Reads `tvshow.nfo` and per-episode `.nfo` files for metadata. Supports standard season folder layouts and flat (seasonless) series.
- **Mixed media library** — folder-navigable browser that mirrors the directory structure on disk. Images open in a lightbox; videos open in an inline player with full seek support.
- **Thumbnail generation** — lazy on-demand thumbnails for images (via `sharp`) and video frames (via `ffmpeg`). Thumbnails are cached to disk in `.thumbnails/` and regenerated when the source file changes.
- **Tag system** — create tag categories and items, then assign tags to individual media items per-library. Filter any library view by one or more tags.
- **Library management UI** — add and remove libraries at `/manage` without touching config files. Configuration persists across restarts in `libraries.json`.
- **User authentication** — iron-session cookie auth with `admin` and `user` roles. Admins manage libraries, scan settings, tags, and users. Users have read-only access, optionally restricted to specific libraries.
- **Background scanner** — scans all libraries on demand (or on a schedule) to pre-generate thumbnails and populate metadata caches.
- **Path-jailed file serving** — all file access is verified to stay within the configured library root before being served.
## Project Structure
@@ -16,35 +21,77 @@ A self-hosted web UI for browsing media libraries on a NAS or local filesystem.
MediaLoreWeb/
├── libraries.json # Runtime library config (managed via UI, do not edit by hand)
├── .thumbnails/ # Disk cache for generated thumbnails (auto-created, gitignored)
├── data/ # Example media (not committed to production)
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty)
│ │ ├── manage/page.tsx # Library management — add/remove libraries
│ │ ├── library/[id]/page.tsx # Library view (games or mixed)
│ │ ├── manage/page.tsx # Library management
│ │ ├── manage/tags/page.tsx # Tag management
│ │ ├── manage/users/page.tsx # User management (admin only)
│ │ ├── manage/scan/page.tsx # Scan settings and manual trigger
│ │ ├── library/[id]/page.tsx # Library view (games / movies / tv / mixed)
│ │ └── api/
│ │ ├── libraries/route.ts # GET /api/libraries, POST /api/libraries
│ │ ├── libraries/[id]/route.ts # DELETE /api/libraries/:id
│ │ ├── games/route.ts # GET /api/games?libraryId=
│ │ ├── browse/route.ts # GET /api/browse?libraryId=&path=
│ │ ├── file/route.ts # GET /api/file?libraryId=&path=
│ │ ── thumbnail/route.ts # GET /api/thumbnail?libraryId=&path=
│ │ ├── auth/login/route.ts # POST /api/auth/login
│ │ ├── auth/logout/route.ts # POST /api/auth/logout
│ │ ├── auth/register/route.ts # POST /api/auth/register
│ │ ├── libraries/route.ts # GET, POST /api/libraries
│ │ ├── libraries/[id]/route.ts # PATCH, DELETE /api/libraries/:id
│ │ ── games/route.ts # GET /api/games
│ │ ├── movies/route.ts # GET /api/movies
│ │ ├── tv/route.ts # GET /api/tv (series, seasons, episodes)
│ │ ├── browse/route.ts # GET /api/browse (mixed media)
│ │ ├── file/route.ts # GET /api/file
│ │ ├── thumbnail/route.ts # GET /api/thumbnail
│ │ ├── game-cover/route.ts # POST /api/game-cover (upload cover art)
│ │ ├── library-cover/[id]/route.ts # GET /api/library-cover/:id
│ │ ├── scan/route.ts # POST /api/scan
│ │ ├── scan-settings/route.ts # GET, POST /api/scan-settings
│ │ ├── settings/route.ts # GET, POST /api/settings
│ │ ├── tags/categories/route.ts # GET, POST /api/tags/categories
│ │ ├── tags/categories/[id]/route.ts# PATCH, DELETE /api/tags/categories/:id
│ │ ├── tags/items/route.ts # GET, POST /api/tags/items
│ │ ├── tags/items/[id]/route.ts # PATCH, DELETE /api/tags/items/:id
│ │ ├── tags/assignments/route.ts # GET, POST, DELETE /api/tags/assignments
│ │ ├── tags/library-assignments/route.ts # GET /api/tags/library-assignments
│ │ ├── users/route.ts # GET, POST /api/users
│ │ ├── users/[id]/route.ts # PATCH, DELETE /api/users/:id
│ │ └── users/[id]/permissions/route.ts # GET, POST /api/users/:id/permissions
│ ├── components/
│ │ ├── FilterPanel.tsx
│ │ ├── LibraryCard.tsx
│ │ ├── NavLink.tsx
│ │ ├── DoomScrollView.tsx
│ │ ├── games/
│ │ │ ├── GamesView.tsx
│ │ │ └── GameDetailModal.tsx
│ │ ── mixed/
│ │ ├── MixedView.tsx
│ │ ── VideoPlayerModal.tsx
│ │ └── ImageLightbox.tsx
│ │ ── movies/
│ │ ├── MoviesView.tsx
│ │ ── MovieDetailModal.tsx
│ │ ├── tv/
│ │ │ └── TvView.tsx
│ │ ├── mixed/
│ │ │ ├── MixedView.tsx
│ │ │ ├── VideoPlayerModal.tsx
│ │ │ └── ImageLightbox.tsx
│ │ └── tags/
│ │ └── TagSelector.tsx
│ ├── lib/
│ │ ├── libraries.ts # Config read/write, path resolution, add/remove helpers
│ │ ├── auth.ts # iron-session auth, requireAdmin / requireLibraryAccess helpers
│ │ ├── db.ts # SQLite setup and migrations (better-sqlite3)
│ │ ├── libraries.ts # Config read/write, path resolution, path-jailing
│ │ ├── media-utils.ts # Shared constants (HIDDEN_FILES, VIDEO_EXTENSIONS) and helpers
│ │ ├── games.ts # Games library scanner
│ │ ├── files.ts # Mixed library directory scanner
│ │ ── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg)
│ │ ├── movies.ts # Movies library scanner
│ │ ── tv.ts # TV library scanner (series / seasons / episodes)
│ │ ├── files.ts # Mixed media directory scanner
│ │ ├── nfo.ts # Kodi-compatible .nfo XML parser
│ │ ├── scanner.ts # Full-library background scan orchestrator
│ │ ├── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg)
│ │ ├── tags.ts # Tag CRUD and assignment helpers
│ │ ├── users.ts # User CRUD and permission helpers
│ │ └── app-settings.ts # App-level settings (scan schedule, etc.)
│ ├── hooks/
│ │ └── useUserSettings.ts
│ └── types/
│ └── index.ts
```
@@ -63,11 +110,14 @@ MediaLoreWeb/
# 1. Install dependencies
npm install
# 2. Start the development server
# 2. Copy the example env file and fill in SESSION_SECRET
cp .env.example .env.local
# 3. Start the development server
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
Open [http://localhost:3000](http://localhost:3000). On first run you will be prompted to create an admin account.
Other available commands:
@@ -77,23 +127,37 @@ npm run start # Start production server (run build first)
npm run lint # Run ESLint
```
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `SESSION_SECRET` | Yes | Secret used to sign session cookies. Must be at least 32 characters. |
| `COOKIE_SECURE` | No | Set to `true` in production (HTTPS). Defaults to `false`. |
## Authentication
MediaLore uses cookie-based sessions (iron-session). On first launch, navigate to `/register` to create the initial admin account. Subsequent registrations require an existing admin to create accounts via `/manage/users`.
**Roles:**
| Role | Capabilities |
|------|-------------|
| `admin` | Full access: manage libraries, users, tags, scan settings |
| `user` | Read-only access to assigned libraries |
Library-level permissions can be configured per user at `/manage/users`.
## Library Configuration
Libraries are managed through the **Manage Libraries** screen at `/manage` in the app. No manual file editing is required.
When you add a library via the UI, you provide:
Libraries are managed through the **Manage Libraries** screen at `/manage` in the app.
| Field | Description |
|--------|-------------|
|-------|-------------|
| Name | Display name shown in the UI |
| Path | Absolute or project-relative path to the library root folder on disk |
| Type | `Games` or `Mixed Media` |
| Type | `games`, `movies`, `tv`, or `mixed` |
The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root and persists across restarts.
If no libraries are configured, navigating to `/` automatically redirects to `/manage`.
Paths can point anywhere on the filesystem — they do not need to be inside the project directory.
The app validates that the path exists as a directory before saving. Configuration is stored in `libraries.json` at the project root.
## Library Folder Conventions
@@ -109,8 +173,41 @@ Games/
└── widecover.jpg # Optional — landscape/hero cover art (case-insensitive)
```
- The `.zip` filename can be anything; the first `.zip` found in the folder is used.
- Cover art filenames are matched case-insensitively against `cover.*` and `widecover.*`. Any image extension is accepted.
Subdirectories without a `.zip` are treated as series containers — their child directories are scanned as individual games.
### Movies (`"type": "movies"`)
Each movie is a subdirectory containing a single video file and optional sidecar files:
```
Movies/
└── The Matrix (1999)/
├── The Matrix (1999).mkv # Required — any supported video extension
├── movie.nfo # Optional — Kodi-compatible metadata
├── poster.jpg # Optional — portrait poster art
└── backdrop.jpg # Optional — backdrop/fanart image
```
Supported video extensions: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.wmv`, `.ts`, `.m2ts`
Poster filenames are matched case-insensitively against `poster`, `cover`, or `folder`. Backdrop filenames are matched against `backdrop`, `fanart`, or `background`.
### TV (`"type": "tv"`)
```
TV/
└── Breaking Bad/
├── tvshow.nfo # Optional — series metadata
├── poster.jpg # Optional — series poster
├── Season 01/
│ ├── s01e01.mkv
│ ├── s01e01.nfo # Optional — episode metadata
│ └── ...
└── Season 02/
└── ...
```
Season directory names are matched against patterns: `Season 01`, `S01`, `1`, `01`. If no season subdirectories contain video files, the series root itself is treated as a flat (seasonless) season.
### Mixed Media (`"type": "mixed"`)
@@ -123,15 +220,57 @@ No specific structure is required. The UI mirrors the directory tree exactly as
All API routes are server-side. File paths are never exposed in client-side state — only opaque `/api/file?...` URLs are sent to the browser.
### Auth
| Route | Method | Description |
|-------|--------|-------------|
| `/api/auth/login` | POST | Authenticate. Body: `{ username, password }` |
| `/api/auth/logout` | POST | Clear session cookie |
| `/api/auth/register` | POST | Create account. Body: `{ username, password }`. First user becomes admin. |
### Libraries
| Route | Method | Description |
|-------|--------|-------------|
| `/api/libraries` | GET | Returns the full configured library list |
| `/api/libraries` | POST | Adds a library. Body: `{ name, path, type }`. Validates the path exists. |
| `/api/libraries` | POST | Adds a library. Body: `{ name, path, type }` |
| `/api/libraries/:id` | PATCH | Updates a library |
| `/api/libraries/:id` | DELETE | Removes a library by id |
| `/api/games?libraryId=<id>` | GET | Scans the games library and returns structured game entries |
| `/api/browse?libraryId=<id>&path=<subpath>` | GET | Lists the contents of a directory within a mixed library |
| `/api/file?libraryId=<id>&path=<relpath>` | GET | Streams a file; supports HTTP `Range` requests for seekable video playback |
| `/api/thumbnail?libraryId=<id>&path=<relpath>` | GET | Returns a cached square thumbnail (JPEG) for an image or video file; `404` if generation fails or ffmpeg is unavailable |
### Media
| Route | Method | Description |
|-------|--------|-------------|
| `/api/games?libraryId=` | GET | Scans the games library and returns structured game entries |
| `/api/movies?libraryId=` | GET | Scans the movies library and returns movie entries |
| `/api/tv?libraryId=` | GET | Returns TV series list |
| `/api/tv?libraryId=&seriesId=` | GET | Returns seasons for a series |
| `/api/tv?libraryId=&seriesId=&seasonId=` | GET | Returns episodes for a season |
| `/api/browse?libraryId=&path=` | GET | Lists the contents of a directory within a mixed library |
| `/api/file?libraryId=&path=` | GET | Streams a file; supports HTTP `Range` requests for seekable video |
| `/api/thumbnail?libraryId=&path=` | GET | Returns a cached square thumbnail (JPEG); `404` if generation fails |
### Tags
| Route | Method | Description |
|-------|--------|-------------|
| `/api/tags/categories` | GET, POST | List or create tag categories |
| `/api/tags/categories/:id` | PATCH, DELETE | Update or delete a category |
| `/api/tags/items` | GET, POST | List or create tag items |
| `/api/tags/items/:id` | PATCH, DELETE | Update or delete a tag item |
| `/api/tags/assignments` | GET, POST, DELETE | Get, add, or remove tag assignments on a media item |
| `/api/tags/library-assignments?libraryId=` | GET | All tag assignments for a library (used by filter panel) |
### Users & Settings
| Route | Method | Description |
|-------|--------|-------------|
| `/api/users` | GET, POST | List or create users (admin only) |
| `/api/users/:id` | PATCH, DELETE | Update or delete a user |
| `/api/users/:id/permissions` | GET, POST | Get or set library-level permissions for a user |
| `/api/settings` | GET, POST | App-level settings |
| `/api/scan` | POST | Trigger a full library scan |
| `/api/scan-settings` | GET, POST | Get or update scan schedule settings |
## Tech Stack
@@ -139,3 +278,7 @@ All API routes are server-side. File paths are never exposed in client-side stat
- [React 19](https://react.dev/)
- [TypeScript 5](https://www.typescriptlang.org/)
- [Tailwind CSS 4](https://tailwindcss.com/)
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) — SQLite database
- [iron-session](https://github.com/vvo/iron-session) — cookie-based sessions
- [sharp](https://sharp.pixelplumbing.com/) — image thumbnail generation
- [ffmpeg](https://ffmpeg.org/) — video thumbnail extraction

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
services:
app:
build: .
ports:
- 3000:3000
environment:
PORT: 3000
NODE_ENV: production
# CONFIG_PATH points db.ts and secret.ts at the config volume so medialore.db
# and .session_secret are created as files inside an existing directory mount.
# Without this Docker will create ./medialore.db on the host as an empty directory,
# which causes better-sqlite3 to fail with SQLITE_CANTOPEN.
CONFIG_PATH: /config
# Set to "true" only when serving over HTTPS (e.g. behind a TLS reverse proxy).
# Keeping this "false" allows the session cookie to be sent over plain HTTP.
COOKIE_SECURE: "false"
volumes:
- ./.thumbnails:/app/.thumbnails
# Library config, database, and session secret — all in one directory volume.
# Initialize before first run:
# mkdir -p config && echo '[]' > config/libraries.json
- ./config:/config
# Mount your media folders and reference them in libraries.json using
# absolute /data/ paths, e.g. { "path": "/data/Games" }
- ./data:/data:ro
# - /path/to/your/movies:/data/Movies:ro
restart: unless-stopped

View File

@@ -1,14 +0,0 @@
[
{
"id": "games",
"name": "Games",
"path": "./data/Games",
"type": "games"
},
{
"id": "various",
"name": "Various",
"path": "./data/Various Media",
"type": "mixed"
}
]

View File

@@ -1,7 +1,8 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
serverExternalPackages: ['better-sqlite3', 'sharp'],
output: 'standalone',
serverExternalPackages: ['better-sqlite3', 'sharp', 'tesseract.js'],
}
export default nextConfig

1393
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,16 +12,26 @@
"author": "",
"license": "ISC",
"dependencies": {
"@types/better-sqlite3": "^7.6.13",
"@tanstack/react-virtual": "^3.13.24",
"@types/adm-zip": "^0.5.8",
"adm-zip": "^0.5.17",
"archiver": "^7.0.1",
"better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10",
"iron-session": "^8.0.4",
"next": "^15.5.14",
"node-cron": "^4.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"tesseract.js": "^7.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/archiver": "^7.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.4",

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getJobQueue, getJobHistory, retryJob, cancelJob, cancelAllQueued, clearJobHistory } from '@/lib/ai-jobs'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const queue = getJobQueue()
const history = getJobHistory(50)
return NextResponse.json({ queue, history })
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: { action?: string; jobId?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { action, jobId } = body
switch (action) {
case 'retry': {
if (!jobId || typeof jobId !== 'string') {
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
}
const ok = retryJob(jobId)
if (!ok) {
return NextResponse.json({ error: 'Job not found or not in failed state' }, { status: 404 })
}
return NextResponse.json({ ok: true })
}
case 'cancel': {
if (!jobId || typeof jobId !== 'string') {
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
}
const ok = cancelJob(jobId)
if (!ok) {
return NextResponse.json({ error: 'Job not found or not in queued state' }, { status: 404 })
}
return NextResponse.json({ ok: true })
}
case 'cancel-all': {
const cancelled = cancelAllQueued()
return NextResponse.json({ cancelled })
}
case 'clear-history': {
const cleared = clearJobHistory()
return NextResponse.json({ cleared })
}
default:
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
}
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibraryAiOverrides, setLibraryAiOverrides } from '@/lib/app-settings'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
return NextResponse.json(getLibraryAiOverrides(id))
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
let body: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
setLibraryAiOverrides(id, {
modelTagging: typeof body.modelTagging === 'string' ? body.modelTagging : undefined,
modelDescribe: typeof body.modelDescribe === 'string' ? body.modelDescribe : undefined,
modelExtract: typeof body.modelExtract === 'string' ? body.modelExtract : undefined,
modelTranslate: typeof body.modelTranslate === 'string' ? body.modelTranslate : undefined,
promptDescribe: typeof body.promptDescribe === 'string' ? body.promptDescribe : undefined,
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
maxTokensTag: typeof body.maxTokensTag === 'number' ? body.maxTokensTag : (body.maxTokensTag === null ? null : undefined),
maxTokensDescribe: typeof body.maxTokensDescribe === 'number' ? body.maxTokensDescribe : (body.maxTokensDescribe === null ? null : undefined),
maxTokensExtract: typeof body.maxTokensExtract === 'number' ? body.maxTokensExtract : (body.maxTokensExtract === null ? null : undefined),
maxTokensTranslate: typeof body.maxTokensTranslate === 'number' ? body.maxTokensTranslate : (body.maxTokensTranslate === null ? null : undefined),
})
return NextResponse.json(getLibraryAiOverrides(id))
}

View File

@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getAiConfig } from '@/lib/app-settings'
export async function GET(request: NextRequest) {
const auth = await requireAuth(request)
if (auth instanceof NextResponse) return auth
const { ocrMode, ocrLanguages } = getAiConfig()
return NextResponse.json({ ocrMode, ocrLanguages })
}

View File

@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const db = getDb()
const result = db.prepare('UPDATE media_items SET ai_tagged_at = NULL').run()
return NextResponse.json({ cleared: result.changes })
}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries, type OcrMode } from '@/lib/app-settings'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const config = getAiConfig()
const preferredLanguage = getPreferredLanguage()
const maxRetries = getAiMaxRetries()
return NextResponse.json({ ...config, preferredLanguage, maxRetries })
}
export async function PUT(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: {
endpoint?: string
model?: string
modelTagging?: string
modelDescribe?: string
modelExtract?: string
modelTranslate?: string
enabled?: boolean
preferredLanguage?: string
promptDescribe?: string
promptTagger?: string
promptExtract?: string
promptTranslate?: string
maxRetries?: number
maxTokensTag?: number
maxTokensDescribe?: number
maxTokensExtract?: number
maxTokensTranslate?: number
ocrMode?: string
ocrLanguages?: string
ocrConfidenceThreshold?: number
}
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const {
endpoint, model, enabled, preferredLanguage,
modelTagging, modelDescribe, modelExtract, modelTranslate,
promptDescribe, promptTagger, promptExtract, promptTranslate,
maxRetries,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
ocrMode, ocrLanguages, ocrConfidenceThreshold,
} = body
if (typeof endpoint !== 'string') {
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
}
if (typeof model !== 'string') {
return NextResponse.json({ error: 'model is required' }, { status: 400 })
}
if (typeof enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 })
}
updateAiConfig(
endpoint,
model,
enabled,
typeof modelTagging === 'string' ? modelTagging : undefined,
typeof modelDescribe === 'string' ? modelDescribe : undefined,
typeof modelExtract === 'string' ? modelExtract : undefined,
typeof modelTranslate === 'string' ? modelTranslate : undefined,
typeof promptDescribe === 'string' ? promptDescribe : undefined,
typeof promptTagger === 'string' ? promptTagger : undefined,
typeof promptExtract === 'string' ? promptExtract : undefined,
typeof promptTranslate === 'string' ? promptTranslate : undefined,
typeof maxTokensTag === 'number' ? maxTokensTag : undefined,
typeof maxTokensDescribe === 'number' ? maxTokensDescribe : undefined,
typeof maxTokensExtract === 'number' ? maxTokensExtract : undefined,
typeof maxTokensTranslate === 'number' ? maxTokensTranslate : undefined,
(ocrMode === 'hybrid' || ocrMode === 'tesseract' || ocrMode === 'llm') ? (ocrMode as OcrMode) : undefined,
typeof ocrLanguages === 'string' ? ocrLanguages : undefined,
typeof ocrConfidenceThreshold === 'number' ? ocrConfidenceThreshold : undefined,
)
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {
setPreferredLanguage(preferredLanguage.trim())
}
if (typeof maxRetries === 'number' && Number.isFinite(maxRetries)) {
setAiMaxRetries(maxRetries)
}
const config = getAiConfig()
return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage(), maxRetries: getAiMaxRetries() })
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getAiConfig } from '@/lib/app-settings'
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { endpoint, model } = getAiConfig()
if (!endpoint) {
return NextResponse.json({ error: 'No endpoint configured' }, { status: 400 })
}
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model: model || 'test',
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 1,
}),
})
clearTimeout(timeout)
if (!res.ok) {
const text = await res.text().catch(() => '')
return NextResponse.json(
{ error: `LLM returned ${res.status}: ${text.slice(0, 200)}` },
{ status: 502 }
)
}
return NextResponse.json({ ok: true })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return NextResponse.json({ error: `Connection failed: ${message}` }, { status: 502 })
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.webm', '.flv', '.ts', '.mpg', '.mpeg'])
const MEDIA_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS])
export async function POST(request: NextRequest) {
let body: { libraryId?: string; path?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { libraryId, path: dirPath } = body
if (!libraryId || typeof libraryId !== 'string') {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) {
let body: { itemKey?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { itemKey } = body
if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'describe', libraryId)
return NextResponse.json({ jobId }, { status: 202 })
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueBulkJobs } from '@/lib/ai-jobs'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
export async function POST(request: NextRequest) {
let body: { libraryId?: string; path?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { libraryId, path: dirPath } = body
if (!libraryId || typeof libraryId !== 'string') {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) {
let body: { itemKey?: string; ocrLanguages?: string; ocrMode?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { itemKey, ocrLanguages, ocrMode } = body
if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const payload: Record<string, string> = {}
if (ocrLanguages) payload.ocrLanguages = ocrLanguages
if (ocrMode) payload.ocrMode = ocrMode
const jobId = enqueueJob(
itemKey,
'extract',
libraryId,
undefined,
Object.keys(payload).length ? payload : undefined,
)
return NextResponse.json({ jobId }, { status: 202 })
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const itemKey = searchParams.get('itemKey')
if (!itemKey) {
return NextResponse.json({ error: 'Missing itemKey' }, { status: 400 })
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const fields = getAiFields(itemKey)
return NextResponse.json(fields)
}
export async function PATCH(request: NextRequest) {
let body: { itemKey?: string; extractedText?: string; aiDescription?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { itemKey, extractedText, aiDescription } = body
if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
if (extractedText === undefined && aiDescription === undefined) {
return NextResponse.json({ error: 'extractedText or aiDescription is required' }, { status: 400 })
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
if (extractedText !== undefined) {
if (typeof extractedText !== 'string') {
return NextResponse.json({ error: 'extractedText must be a string' }, { status: 400 })
}
updateExtractedText(itemKey, extractedText)
}
if (aiDescription !== undefined) {
if (typeof aiDescription !== 'string') {
return NextResponse.json({ error: 'aiDescription must be a string' }, { status: 400 })
}
updateAiDescription(itemKey, aiDescription)
}
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) {
let body: { itemKey?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { itemKey } = body
if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'tag', libraryId)
return NextResponse.json({ jobId }, { status: 202 })
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
import { getDb } from '@/lib/db'
export async function POST(request: NextRequest) {
let body: { libraryId?: string; path?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { libraryId, path: dirPath } = body
if (!libraryId || typeof libraryId !== 'string') {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const db = getDb()
const prefix = dirPath
? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
: `${libraryId}:mixed_file:`
// Only enqueue translate jobs for items that already have extracted text
const items = db
.prepare(
'SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = ? AND extracted_text IS NOT NULL'
)
.all(`${prefix}%`, 'mixed_file') as { item_key: string }[]
const jobIds = items.map(({ item_key }) => enqueueJob(item_key, 'translate', libraryId))
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryWriteAccess } from '@/lib/auth'
import { enqueueJob } from '@/lib/ai-jobs'
export async function POST(request: NextRequest) {
let body: { itemKey?: string; sourceLanguage?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { itemKey, sourceLanguage } = body
if (!itemKey || typeof itemKey !== 'string') {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
const libraryId = itemKey.split(':')[0]
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
return NextResponse.json({ jobId }, { status: 202 })
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { getWritableSession, verifyPassword, type SessionData } from '@/lib/auth'
import { getUserByUsername } from '@/lib/users'
export async function POST(request: NextRequest) {
let body: { username?: string; password?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { username, password } = body
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 })
}
const user = getUserByUsername(username)
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const response = NextResponse.json({ role: user.role })
const session = await getWritableSession(request, response)
session.userId = user.id
session.username = user.username
session.role = user.role
await session.save()
return response
}

View File

@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { getWritableSession } from '@/lib/auth'
export async function POST(request: NextRequest) {
const response = new NextResponse(null, { status: 204 })
const session = await getWritableSession(request, response)
session.destroy()
return response
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSessionOptions, hashPassword, type SessionData } from '@/lib/auth'
import { getIronSession } from 'iron-session'
import { getUserCount, createUser } from '@/lib/users'
export async function POST(request: NextRequest) {
let body: { username?: string; password?: string; role?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { username, password } = body
let { role } = body
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 })
}
if (username.trim().length < 2) {
return NextResponse.json({ error: 'Username must be at least 2 characters' }, { status: 400 })
}
if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 })
}
const userCount = getUserCount()
if (userCount === 0) {
// First user always becomes admin
role = 'admin'
} else {
// Subsequent users require an admin session
const res = new NextResponse()
const session = await getIronSession<SessionData>(request, res, getSessionOptions())
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (role !== 'admin' && role !== 'user') {
role = 'user'
}
}
const passwordHash = await hashPassword(password)
try {
const user = createUser(username.trim(), passwordHash, role as 'admin' | 'user')
return NextResponse.json({ id: user.id, username: user.username, role: user.role }, { status: 201 })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create user'
if (message.includes('UNIQUE constraint failed')) {
return NextResponse.json({ error: 'Username already taken' }, { status: 409 })
}
return NextResponse.json({ error: message }, { status: 400 })
}
}

View File

@@ -1,8 +1,13 @@
import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
import { scanDirectory } from '@/lib/files'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
import { removeAllAssignmentsForItem } from '@/lib/tags'
import { getDb } from '@/lib/db'
export function GET(request: NextRequest) {
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const subpath = searchParams.get('path') ?? ''
@@ -11,6 +16,101 @@ export function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'mixed') {
return NextResponse.json({ error: 'Library is not a mixed library' }, { status: 400 })
}
const root = resolveLibraryRoot(library)
const recursive = request.nextUrl.searchParams.get('recursive') === 'true'
const listing = recursive
? await scanDirectoryRecursive(root, libraryId, subpath)
: scanDirectory(root, libraryId, subpath)
// Annotate entries with metadata used by search/filtering in mixed view.
const db = getDb()
const metadataRows = db
.prepare(`
SELECT item_key, user_rating, ai_description, extracted_text, extracted_text_translated
FROM media_items
WHERE library_id = ?
AND (
user_rating IS NOT NULL
OR ai_description IS NOT NULL
OR extracted_text IS NOT NULL
OR extracted_text_translated IS NOT NULL
)
`)
.all(libraryId) as {
item_key: string
user_rating: number | null
ai_description: string | null
extracted_text: string | null
extracted_text_translated: string | null
}[]
const metadataByItemKey = new Map(metadataRows.map((r) => [r.item_key, r]))
const withText = new Set(
metadataRows
.filter((r) => r.extracted_text !== null)
.map((r) => r.item_key)
)
// Build a set of all ancestor directory relative paths that contain at least one item with text
// e.g. item_key "lib:mixed_file:manga%2Fch1%2Fp1.jpg" → ancestors "manga", "manga/ch1"
const dirsWithText = new Set<string>()
const keyPrefix = `${libraryId}:mixed_file:`
for (const key of withText) {
const decoded = decodeURIComponent(key.slice(keyPrefix.length))
const parts = decoded.split('/')
for (let i = 1; i < parts.length; i++) {
dirsWithText.add(parts.slice(0, i).join('/'))
}
}
listing.entries = listing.entries.map((e) => {
if (e.type === 'file') {
// Recursive listing already uses full path from library root in e.name.
const relPath = recursive ? e.name : (subpath ? path.join(subpath, e.name) : e.name)
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
const metadata = metadataByItemKey.get(itemKey)
return {
...e,
...(e.mediaType === 'image' ? { hasExtractedText: withText.has(itemKey) } : {}),
userRating: metadata?.user_rating ?? null,
aiDescription: metadata?.ai_description ?? null,
extractedText: metadata?.extracted_text ?? null,
extractedTextTranslated: metadata?.extracted_text_translated ?? null,
}
}
if (e.type === 'directory') {
const dirRel = subpath ? `${subpath}/${e.name}` : e.name
if (dirsWithText.has(dirRel)) return { ...e, hasExtractedText: true }
}
return e
})
return NextResponse.json(listing)
}
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const itemPath = searchParams.get('path')
if (!libraryId || !itemPath) {
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
@@ -20,6 +120,36 @@ export function GET(request: NextRequest) {
}
const root = resolveLibraryRoot(library)
const listing = scanDirectory(root, libraryId, subpath)
return NextResponse.json(listing)
let absPath: string
try {
absPath = resolveAndJail(root, itemPath)
} catch {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
}
try {
const stat = fs.statSync(absPath)
if (stat.isDirectory()) {
fs.rmSync(absPath, { recursive: true, force: true })
} else {
fs.unlinkSync(absPath)
}
} catch {
return NextResponse.json({ error: 'Failed to delete' }, { status: 500 })
}
const db = getDb()
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(itemPath)}`
removeAllAssignmentsForItem(itemKey)
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(itemKey)
// For directories, also clean up children
const prefix = `${libraryId}:mixed_file:${encodeURIComponent(itemPath + '/')}`
const children = db.prepare('SELECT item_key FROM media_items WHERE item_key LIKE ?').all(`${prefix}%`) as { item_key: string }[]
for (const child of children) {
removeAllAssignmentsForItem(child.item_key)
}
db.prepare('DELETE FROM media_items WHERE item_key LIKE ?').run(`${prefix}%`)
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { getComicPageBuffer } from '@/lib/comics'
import { requireLibraryAccess } from '@/lib/auth'
import { getDb } from '@/lib/db'
const EXT_TO_MIME: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const issueKey = searchParams.get('issueKey')
const pageIndexStr = searchParams.get('pageIndex')
if (!libraryId || !issueKey || pageIndexStr === null) {
return NextResponse.json({ error: 'Missing libraryId, issueKey, or pageIndex' }, { status: 400 })
}
const pageIndex = parseInt(pageIndexStr, 10)
if (isNaN(pageIndex) || pageIndex < 0) {
return NextResponse.json({ error: 'Invalid pageIndex' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
const db = getDb()
const row = db
.prepare('SELECT file_path FROM media_items WHERE item_key = ? AND item_type = ?')
.get(issueKey, 'comic_issue') as { file_path: string | null } | undefined
if (!row?.file_path) {
return NextResponse.json({ error: 'Issue not found' }, { status: 404 })
}
const root = resolveLibraryRoot(library)
let absPath: string
try {
absPath = resolveAndJail(root, row.file_path)
} catch {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const result = getComicPageBuffer(absPath, pageIndex)
if (!result) {
return NextResponse.json({ error: 'Page not found' }, { status: 404 })
}
const mimeType = EXT_TO_MIME[result.ext] ?? 'image/jpeg'
return new NextResponse(result.buffer as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type': mimeType,
'Content-Length': String(result.buffer.length),
'Cache-Control': 'public, max-age=86400',
},
})
}

117
src/app/api/comics/route.ts Normal file
View File

@@ -0,0 +1,117 @@
import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { comicsFromDb, comicIssuesFromDb } from '@/lib/comics'
import { removeAllAssignmentsForItem } from '@/lib/tags'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const seriesId = searchParams.get('seriesId')
if (!libraryId) {
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'comics') {
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
}
if (seriesId) {
return NextResponse.json(comicIssuesFromDb(libraryId, seriesId))
}
const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1)
const pageSize = Math.min(500, Math.max(1, parseInt(searchParams.get('pageSize') ?? '200', 10) || 200))
const search = (searchParams.get('search') ?? '').trim() || undefined
return NextResponse.json(comicsFromDb(libraryId, { page, pageSize, search }))
}
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const issueKey = searchParams.get('issueKey')
const seriesId = searchParams.get('seriesId')
if (!libraryId) {
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'comics') {
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
}
const root = resolveLibraryRoot(library)
if (issueKey) {
const db = getDb()
const row = db
.prepare('SELECT file_path FROM media_items WHERE item_key = ? AND item_type = ?')
.get(issueKey, 'comic_issue') as { file_path: string | null } | undefined
if (!row?.file_path) {
return NextResponse.json({ error: 'Issue not found' }, { status: 404 })
}
let issuePath: string
try {
issuePath = resolveAndJail(root, row.file_path)
} catch {
return NextResponse.json({ error: 'Invalid issue path' }, { status: 400 })
}
try {
fs.unlinkSync(issuePath)
} catch {
return NextResponse.json({ error: 'Failed to delete issue file' }, { status: 500 })
}
removeAllAssignmentsForItem(issueKey)
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(issueKey)
return new NextResponse(null, { status: 204 })
}
if (seriesId) {
const dirName = decodeURIComponent(seriesId)
let seriesDir: string
try {
seriesDir = resolveAndJail(root, dirName)
} catch {
return NextResponse.json({ error: 'Invalid series path' }, { status: 400 })
}
try {
fs.rmSync(seriesDir, { recursive: true, force: true })
} catch {
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
}
removeAllAssignmentsForItem(`${libraryId}:comic_series:${seriesId}`)
const db = getDb()
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(`${libraryId}:comic_series:${seriesId}`)
return new NextResponse(null, { status: 204 })
}
return NextResponse.json({ error: 'Missing issueKey or seriesId' }, { status: 400 })
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary } from '@/lib/libraries'
import { getComicsSeriesIssueMeta } from '@/lib/tags'
import { requireLibraryAccess } from '@/lib/auth'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
if (!libraryId) {
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'comics') {
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
}
return NextResponse.json(getComicsSeriesIssueMeta(libraryId))
}

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import archiver from 'archiver'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireLibraryAccess } from '@/lib/auth'
const MIME_TYPES: Record<string, string> = {
'.mp4': 'video/mp4',
@@ -18,14 +20,41 @@ const MIME_TYPES: Record<string, string> = {
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.tif': 'image/tiff',
'.cbz': 'application/zip',
'.zip': 'application/zip',
'.dmg': 'application/x-apple-diskimage',
'.gz': 'application/gzip',
'.tgz': 'application/gzip',
'.bz2': 'application/x-bzip2',
'.xz': 'application/x-xz',
'.zst': 'application/zstd',
}
function getMimeType(filePath: string): string {
// Special-case multi-part extensions before checking the last extension
const lower = filePath.toLowerCase()
if (lower.endsWith('.tar.gz')) return 'application/gzip'
if (lower.endsWith('.tar.bz2')) return 'application/x-bzip2'
if (lower.endsWith('.tar.xz')) return 'application/x-xz'
if (lower.endsWith('.tar.zst')) return 'application/zstd'
const ext = path.extname(filePath).toLowerCase()
return MIME_TYPES[ext] ?? 'application/octet-stream'
}
function isDownloadAttachment(filePath: string): boolean {
const lower = filePath.toLowerCase()
return (
lower.endsWith('.cbz') ||
lower.endsWith('.zip') ||
lower.endsWith('.tar.gz') ||
lower.endsWith('.tar.bz2') ||
lower.endsWith('.tar.xz') ||
lower.endsWith('.tar.zst') ||
lower.endsWith('.tgz') ||
lower.endsWith('.dmg')
)
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
@@ -35,6 +64,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
@@ -56,6 +88,25 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
// .app bundle: stream the directory as a zip archive on the fly
if (stat.isDirectory() && subpath.toLowerCase().endsWith('.app')) {
const bundleName = path.basename(filePath)
const zipName = `${bundleName}.zip`
const archive = archiver('zip', { zlib: { level: 6 } })
archive.directory(filePath, bundleName)
archive.finalize()
return new NextResponse(archive as unknown as ReadableStream, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(zipName)}"`,
'Cache-Control': 'no-store',
},
})
}
if (!stat.isFile()) {
return NextResponse.json({ error: 'Not a file' }, { status: 400 })
}
@@ -64,9 +115,7 @@ export async function GET(request: NextRequest) {
const fileSize = stat.size
const rangeHeader = request.headers.get('range')
// Handle ZIP as a download
const isZip = path.extname(filePath).toLowerCase() === '.zip'
const contentDisposition = isZip
const contentDisposition = isDownloadAttachment(filePath)
? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`
: `inline; filename="${encodeURIComponent(path.basename(filePath))}"`

View File

@@ -0,0 +1,115 @@
import path from 'path'
import fs from 'fs'
import sharp from 'sharp'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
type CoverType = 'cover' | 'widecover'
function isCoverType(s: string | null): s is CoverType {
return s === 'cover' || s === 'widecover'
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const itemId = searchParams.get('itemId')
const coverType = searchParams.get('coverType')
if (!libraryId || !itemId) {
return NextResponse.json({ error: 'Missing libraryId or itemId' }, { status: 400 })
}
if (!isCoverType(coverType)) {
return NextResponse.json({ error: 'coverType must be "cover" or "widecover"' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'games') {
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
}
const libraryRoot = resolveLibraryRoot(library)
const folderPath = decodeURIComponent(itemId)
let resolvedDir: string
try {
resolvedDir = resolveAndJail(libraryRoot, folderPath)
} catch {
return NextResponse.json({ error: 'Invalid item path' }, { status: 400 })
}
if (!fs.existsSync(resolvedDir)) {
return NextResponse.json({ error: 'Game folder not found' }, { status: 404 })
}
let formData: FormData
try {
formData = await request.formData()
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 })
}
const file = formData.get('cover')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'cover file is required' }, { status: 400 })
}
if (file.size > MAX_COVER_BYTES) {
return NextResponse.json({ error: 'File too large. Maximum size is 10 MB.' }, { status: 400 })
}
const rawBuffer = Buffer.from(await file.arrayBuffer())
let processedBuffer: Buffer
try {
processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer()
} catch {
return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 })
}
const destFilename = `${coverType}.jpg`
const destPath = path.join(resolvedDir, destFilename)
// Remove any existing file with the same base name but a different extension
const basePattern = new RegExp(`^${coverType}\\.`, 'i')
try {
for (const f of fs.readdirSync(resolvedDir)) {
if (basePattern.test(f) && f.toLowerCase() !== destFilename) {
fs.unlinkSync(path.join(resolvedDir, f))
}
}
} catch { /* ignore */ }
fs.writeFileSync(destPath, processedBuffer)
const relPath = path.join(folderPath, destFilename)
// cover uses the thumbnail endpoint; widecover is served directly
const url =
coverType === 'cover'
? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
// Update DB metadata so the new cover is visible without a re-scan
const db = getDb()
const itemKey = `${libraryId}:game:${itemId}`
const row = db.prepare('SELECT metadata FROM media_items WHERE item_key = ?').get(itemKey) as { metadata: string | null } | undefined
if (row) {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
if (coverType === 'cover') meta.coverUrl = url
else meta.wideCoverUrl = url
db.prepare('UPDATE media_items SET metadata = ? WHERE item_key = ?').run(JSON.stringify(meta), itemKey)
}
return NextResponse.json({ url }, { status: 200 })
}

View File

@@ -0,0 +1,177 @@
import path from 'path'
import fs from 'fs'
import sharp from 'sharp'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireAdmin, requireLibraryAccess } from '@/lib/auth'
import { fileApiUrl, thumbnailApiUrl } from '@/lib/media-utils'
const SCREENSHOT_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024 // 20 MB
type GameDirResult =
| { gameDir: string; screenshotsDir: string; folderPath: string }
| { error: string; status: number }
function getGameDir(libraryId: string, gameId: string): GameDirResult {
const library = getLibrary(libraryId)
if (!library) return { error: 'Library not found', status: 404 }
if (library.type !== 'games') return { error: 'Library is not a games library', status: 400 }
const libraryRoot = resolveLibraryRoot(library)
const folderPath = decodeURIComponent(gameId)
let gameDir: string
try {
gameDir = resolveAndJail(libraryRoot, folderPath)
} catch {
return { error: 'Invalid game path', status: 400 }
}
if (!fs.existsSync(gameDir)) return { error: 'Game folder not found', status: 404 }
return { gameDir, screenshotsDir: path.join(gameDir, 'screenshots'), folderPath }
}
// ─── GET: list screenshots ────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const gameId = searchParams.get('gameId')
if (!libraryId || !gameId) {
return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const resolved = getGameDir(libraryId, gameId)
if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status })
const { screenshotsDir, folderPath } = resolved
if (!fs.existsSync(screenshotsDir)) {
return NextResponse.json({ screenshots: [] })
}
let files: string[]
try {
files = fs.readdirSync(screenshotsDir)
} catch {
return NextResponse.json({ screenshots: [] })
}
const screenshots = files
.filter((f) => SCREENSHOT_EXTENSIONS.has(path.extname(f).toLowerCase()))
.sort()
.map((filename) => {
const relPath = path.join(folderPath, 'screenshots', filename)
return {
filename,
url: fileApiUrl(libraryId, relPath),
thumbnailUrl: thumbnailApiUrl(libraryId, relPath),
}
})
return NextResponse.json({ screenshots })
}
// ─── POST: upload screenshot ──────────────────────────────────────────────────
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const gameId = searchParams.get('gameId')
if (!libraryId || !gameId) {
return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 })
}
const resolved = getGameDir(libraryId, gameId)
if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status })
const { screenshotsDir, folderPath } = resolved
let formData: FormData
try {
formData = await request.formData()
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 })
}
const file = formData.get('screenshot')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'screenshot field is required' }, { status: 400 })
}
if (file.size > MAX_SCREENSHOT_BYTES) {
return NextResponse.json({ error: 'File too large. Maximum size is 20 MB.' }, { status: 400 })
}
const rawBuffer = Buffer.from(await file.arrayBuffer())
let processedBuffer: Buffer
try {
processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer()
} catch {
return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 })
}
fs.mkdirSync(screenshotsDir, { recursive: true })
const filename = `shot-${Date.now()}.jpg`
fs.writeFileSync(path.join(screenshotsDir, filename), processedBuffer)
const relPath = path.join(folderPath, 'screenshots', filename)
return NextResponse.json(
{
filename,
url: fileApiUrl(libraryId, relPath),
thumbnailUrl: thumbnailApiUrl(libraryId, relPath),
},
{ status: 201 }
)
}
// ─── DELETE: remove screenshot ────────────────────────────────────────────────
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const gameId = searchParams.get('gameId')
const filename = searchParams.get('filename')
if (!libraryId || !gameId || !filename) {
return NextResponse.json({ error: 'Missing libraryId, gameId, or filename' }, { status: 400 })
}
// Filename must be a plain basename — no path separators, no traversal
if (filename !== path.basename(filename) || filename.includes('..')) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })
}
const resolved = getGameDir(libraryId, gameId)
if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status })
const { screenshotsDir } = resolved
let filePath: string
try {
filePath = resolveAndJail(screenshotsDir, filename)
} catch {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })
}
try {
fs.unlinkSync(filePath)
} catch {
return NextResponse.json({ error: 'File not found or could not be deleted' }, { status: 404 })
}
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,8 +1,12 @@
import fs from 'fs'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
import { scanGamesLibrary } from '@/lib/games'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { gamesFromDb } from '@/lib/games'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
import { removeAllAssignmentsForItem } from '@/lib/tags'
import { getDb } from '@/lib/db'
export function GET(request: NextRequest) {
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
@@ -10,6 +14,32 @@ export function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'games') {
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
}
return NextResponse.json(gamesFromDb(libraryId))
}
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const gameId = searchParams.get('gameId')
if (!libraryId || !gameId) {
return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
@@ -19,6 +49,24 @@ export function GET(request: NextRequest) {
}
const root = resolveLibraryRoot(library)
const games = scanGamesLibrary(root, libraryId)
return NextResponse.json(games)
const dirName = decodeURIComponent(gameId)
let gameDir: string
try {
gameDir = resolveAndJail(root, dirName)
} catch {
return NextResponse.json({ error: 'Invalid game path' }, { status: 400 })
}
try {
fs.rmSync(gameDir, { recursive: true, force: true })
} catch {
return NextResponse.json({ error: 'Failed to delete game directory' }, { status: 500 })
}
const itemKey = `${libraryId}:game:${gameId}`
removeAllAssignmentsForItem(itemKey)
getDb().prepare('DELETE FROM media_items WHERE item_key = ?').run(itemKey)
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'
import { getImportedTagsForLibrary } from '@/lib/comic-metadata'
import { requireAdmin } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const libraryId = request.nextUrl.searchParams.get('libraryId')
if (!libraryId) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const tags = getImportedTagsForLibrary(libraryId)
return NextResponse.json(tags)
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibrary } from '@/lib/libraries'
import { getDb } from '@/lib/db'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id: libraryId } = await params
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'comics') {
return NextResponse.json({ error: 'Only comics libraries support bulk rename' }, { status: 400 })
}
const body = await request.json()
const { pattern, preview } = body as { pattern: string; preview?: boolean }
if (!pattern || typeof pattern !== 'string') {
return NextResponse.json({ error: 'Pattern is required' }, { status: 400 })
}
// Validate regex
let regex: RegExp
try {
regex = new RegExp(pattern, 'g')
} catch {
return NextResponse.json({ error: 'Invalid regex pattern' }, { status: 400 })
}
const db = getDb()
const rows = db
.prepare(
`SELECT item_key, title FROM media_items
WHERE library_id = ? AND item_type IN ('comic_series', 'comic_issue')`
)
.all(libraryId) as { item_key: string; title: string }[]
const changes: { itemKey: string; oldTitle: string; newTitle: string }[] = []
for (const row of rows) {
// Reset lastIndex since we reuse the regex with 'g' flag
regex.lastIndex = 0
const newTitle = row.title.replace(regex, '').trim()
if (newTitle && newTitle !== row.title) {
changes.push({ itemKey: row.item_key, oldTitle: row.title, newTitle })
}
}
if (preview) {
return NextResponse.json({ changes })
}
// Apply
const stmt = db.prepare('UPDATE media_items SET title = ? WHERE item_key = ?')
db.transaction(() => {
for (const c of changes) {
stmt.run(c.newTitle, c.itemKey)
}
})()
return NextResponse.json({ updated: changes.length })
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibrary } from '@/lib/libraries'
import { importMovieMetadata } from '@/lib/movie-metadata'
export async function POST(request: NextRequest): Promise<NextResponse> {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { pathname } = new URL(request.url)
const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-movies
try {
const library = getLibrary(libraryId)
if (!library || library.type !== 'movies') {
return NextResponse.json({ error: 'Movies library not found' }, { status: 404 })
}
// Perform full metadata import for all items
const result = await importMovieMetadata(library, true)
return NextResponse.json(result)
} catch (err) {
console.error('[import-metadata-movies]', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to import metadata' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibrary } from '@/lib/libraries'
import { importTvMetadata } from '@/lib/tv-metadata'
export async function POST(request: NextRequest): Promise<NextResponse> {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { pathname } = new URL(request.url)
const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-tv
try {
const library = getLibrary(libraryId)
if (!library || library.type !== 'tv') {
return NextResponse.json({ error: 'TV library not found' }, { status: 404 })
}
// Perform full metadata import for all items
const result = await importTvMetadata(library, true)
return NextResponse.json(result)
} catch (err) {
console.error('[import-metadata-tv]', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to import metadata' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary } from '@/lib/libraries'
import { importComicMetadata } from '@/lib/comic-metadata'
import { requireAdmin } from '@/lib/auth'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const library = getLibrary(id)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'comics') {
return NextResponse.json({ error: 'Metadata import is only supported for comic libraries' }, { status: 400 })
}
// Fire-and-forget
void Promise.resolve().then(() => {
try {
importComicMetadata(library)
} catch (err) {
console.error(`[import-metadata] Error importing metadata for "${library.name}":`, err)
}
})
return new NextResponse(null, { status: 202 })
}

View File

@@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, removeLibrary } from '@/lib/libraries'
import { removeAllAssignmentsForLibrary } from '@/lib/tags'
import { requireAdmin } from '@/lib/auth'
export async function DELETE(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const library = getLibrary(id)

View File

@@ -1,10 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibraries, addLibrary } from '@/lib/libraries'
import { getLibrariesForUser } from '@/lib/users'
import { requireAuth, requireAdmin } from '@/lib/auth'
import type { LibraryType } from '@/types'
export function GET() {
export async function GET(request: NextRequest) {
const auth = await requireAuth(request)
if (auth instanceof NextResponse) return auth
const { session } = auth
try {
const libraries = getLibraries()
const libraries =
session.role === 'admin'
? getLibraries().map((l) => ({ ...l, accessLevel: 'admin' }))
: getLibrariesForUser(session.userId, session.role)
return NextResponse.json(libraries)
} catch (err) {
console.error('Failed to read libraries', err)
@@ -13,6 +22,9 @@ export function GET() {
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: { name?: string; path?: string; type?: string }
try {
body = await request.json()
@@ -26,7 +38,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
}
const validTypes: LibraryType[] = ['games', 'mixed']
const validTypes: LibraryType[] = ['comics', 'games', 'mixed', 'movies', 'tv']
if (!validTypes.includes(type as LibraryType)) {
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
}

View File

@@ -0,0 +1,113 @@
import path from 'path'
import fs from 'fs'
import sharp from 'sharp'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, updateLibraryCover, clearLibraryCover } from '@/lib/libraries'
import { requireAuth, requireAdmin } from '@/lib/auth'
const COVERS_DIR = path.resolve(process.cwd(), '.covers')
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
function coverPath(id: string, ext: string) {
return path.join(COVERS_DIR, `${id}.${ext}`)
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const library = getLibrary(id)
if (!library?.coverExt) {
return new NextResponse(null, { status: 404 })
}
const filePath = coverPath(id, library.coverExt)
try {
const data = fs.readFileSync(filePath)
return new NextResponse(data, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=86400',
},
})
} catch {
return new NextResponse(null, { status: 404 })
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const library = getLibrary(id)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
let formData: FormData
try {
formData = await request.formData()
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 })
}
const file = formData.get('cover')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'cover file is required' }, { status: 400 })
}
if (file.size > MAX_COVER_BYTES) {
return NextResponse.json({ error: 'File too large. Maximum size is 10 MB.' }, { status: 400 })
}
const rawBuffer = Buffer.from(await file.arrayBuffer())
// Re-encode through sharp — validates it's a real image and strips metadata
let processedBuffer: Buffer
try {
processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer()
} catch {
return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 })
}
fs.mkdirSync(COVERS_DIR, { recursive: true })
// Remove any existing cover (may have a different extension from older uploads)
if (library.coverExt) {
try { fs.unlinkSync(coverPath(id, library.coverExt)) } catch { /* ignore */ }
}
fs.writeFileSync(coverPath(id, 'jpg'), processedBuffer)
updateLibraryCover(id, 'jpg')
return NextResponse.json({ coverExt: 'jpg' }, { status: 200 })
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const library = getLibrary(id)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.coverExt) {
try { fs.unlinkSync(coverPath(id, library.coverExt)) } catch { /* ignore */ }
clearLibraryCover(id)
}
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
export async function PATCH(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const body = await request.json()
const { itemKey, title, year, plot, genres } = body as {
itemKey: string
title?: string
year?: number | null
plot?: string | null
genres?: string[]
}
if (!itemKey) {
return NextResponse.json({ error: 'Missing itemKey' }, { status: 400 })
}
const db = getDb()
const row = db.prepare('SELECT metadata FROM media_items WHERE item_key = ?').get(itemKey) as { metadata: string | null } | undefined
if (!row) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
}
const sets: string[] = []
const params: Record<string, unknown> = { item_key: itemKey }
if (title !== undefined) {
sets.push('title = @title')
params.title = title
}
if (year !== undefined) {
sets.push('year = @year')
params.year = year
}
if (plot !== undefined) {
sets.push('plot = @plot')
params.plot = plot
}
if (genres !== undefined) {
sets.push('genres = @genres')
params.genres = JSON.stringify(genres)
}
// Always mark as manually edited in the metadata blob
const existingMeta = row.metadata ? JSON.parse(row.metadata) : {}
existingMeta.manuallyEdited = true
sets.push('metadata = @metadata')
params.metadata = JSON.stringify(existingMeta)
if (sets.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 })
}
db.prepare(`UPDATE media_items SET ${sets.join(', ')} WHERE item_key = @item_key`).run(params)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,70 @@
import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { moviesFromDb } from '@/lib/movies'
import { removeAllAssignmentsForItem } from '@/lib/tags'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
if (!libraryId) {
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'movies') {
return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 })
}
return NextResponse.json(moviesFromDb(libraryId))
}
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const movieId = searchParams.get('movieId')
if (!libraryId || !movieId) {
return NextResponse.json({ error: 'Missing libraryId or movieId' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'movies') {
return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 })
}
const root = resolveLibraryRoot(library)
const dirName = decodeURIComponent(movieId)
let movieDir: string
try {
movieDir = resolveAndJail(root, dirName)
} catch {
return NextResponse.json({ error: 'Invalid movie path' }, { status: 400 })
}
try {
fs.rmSync(movieDir, { recursive: true, force: true })
} catch {
return NextResponse.json({ error: 'Failed to delete movie directory' }, { status: 500 })
}
removeAllAssignmentsForItem(`${libraryId}:movie:${movieId}`)
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,198 @@
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
import { findNfoFile } from '@/lib/movies'
import { parseMovieNfo, parseTvShowNfo, parseEpisodeNfo } from '@/lib/nfo'
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const itemType = searchParams.get('itemType') as 'movie' | 'tv_series' | 'tv_episode' | null
const itemKey = searchParams.get('itemKey')
if (!libraryId || !itemType || !itemKey) {
return NextResponse.json({ error: 'Missing libraryId, itemType, or itemKey' }, { status: 400 })
}
if (!['movie', 'tv_series', 'tv_episode'].includes(itemType)) {
return NextResponse.json({ error: 'itemType must be movie, tv_series, or tv_episode' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
const db = getDb()
const row = db
.prepare('SELECT * FROM media_items WHERE item_key = ?')
.get(itemKey) as {
item_key: string
item_type: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
} | undefined
if (!row) {
return NextResponse.json({ error: 'Item not found in database' }, { status: 404 })
}
const libraryRoot = resolveLibraryRoot(library)
const existingMeta = row.metadata ? JSON.parse(row.metadata) : {}
if (itemType === 'movie') {
// item_key: {libraryId}:movie:{encodedDirName}
const encodedDirName = itemKey.split(':movie:')[1]
if (!encodedDirName) {
return NextResponse.json({ error: 'Invalid item key' }, { status: 400 })
}
const dirName = decodeURIComponent(encodedDirName)
const movieDir = path.join(libraryRoot, dirName)
const nfoFileName = findNfoFile(movieDir, dirName)
if (!nfoFileName) {
return NextResponse.json({ updated: false, reason: 'no nfo found' })
}
const nfo = parseMovieNfo(path.join(movieDir, nfoFileName))
if (!nfo) {
return NextResponse.json({ updated: false, reason: 'nfo parse failed' })
}
db.prepare(`
UPDATE media_items SET
title = @title,
year = @year,
plot = @plot,
genres = @genres,
metadata = @metadata
WHERE item_key = @item_key
`).run({
item_key: itemKey,
title: nfo.title ?? row.title,
year: nfo.year ?? null,
plot: nfo.plot ?? null,
genres: JSON.stringify(nfo.genres ?? []),
metadata: JSON.stringify({
...existingMeta,
rating: nfo.rating ?? null,
runtime: nfo.runtime ?? null,
}),
})
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year })
}
if (itemType === 'tv_series') {
// item_key: {libraryId}:tv_series:{encodedDirName}
const encodedDirName = itemKey.split(':tv_series:')[1]
if (!encodedDirName) {
return NextResponse.json({ error: 'Invalid item key' }, { status: 400 })
}
const dirName = decodeURIComponent(encodedDirName)
const seriesDir = path.join(libraryRoot, dirName)
const nfoPath = path.join(seriesDir, 'tvshow.nfo')
const nfo = parseTvShowNfo(nfoPath)
if (!nfo) {
return NextResponse.json({ updated: false, reason: 'no nfo found' })
}
db.prepare(`
UPDATE media_items SET
title = @title,
year = @year,
plot = @plot,
genres = @genres,
metadata = @metadata
WHERE item_key = @item_key
`).run({
item_key: itemKey,
title: nfo.title ?? row.title,
year: nfo.year ?? null,
plot: nfo.plot ?? null,
genres: JSON.stringify(nfo.genres ?? []),
metadata: JSON.stringify({
...existingMeta,
status: nfo.status ?? null,
}),
})
// Optionally also refresh every episode NFO in this series
let episodesUpdated = 0
const includeEpisodes = searchParams.get('includeEpisodes') === 'true'
if (includeEpisodes) {
type EpRow = { item_key: string; file_path: string | null; metadata: string | null }
const episodeRows = db
.prepare(`SELECT item_key, file_path, metadata FROM media_items WHERE item_type = 'tv_episode' AND item_key LIKE ?`)
.all(`${libraryId}:tv_episode:${encodedDirName}:%`) as EpRow[]
const updateEp = db.prepare(`
UPDATE media_items SET title = @title, plot = @plot, metadata = @metadata WHERE item_key = @item_key
`)
db.transaction(() => {
for (const ep of episodeRows) {
if (!ep.file_path) continue
const epDir = path.join(libraryRoot, path.dirname(ep.file_path))
const baseName = path.basename(ep.file_path, path.extname(ep.file_path))
const epNfo = parseEpisodeNfo(path.join(epDir, `${baseName}.nfo`))
if (!epNfo) continue
const epMeta = ep.metadata ? JSON.parse(ep.metadata) : {}
updateEp.run({
item_key: ep.item_key,
title: epNfo.title ?? null,
plot: epNfo.plot ?? null,
metadata: JSON.stringify({
...epMeta,
episodeNumber: epNfo.episode ?? epMeta.episodeNumber ?? null,
seasonNumber: epNfo.season ?? epMeta.seasonNumber ?? null,
aired: epNfo.aired ?? null,
rating: epNfo.rating ?? null,
}),
})
episodesUpdated++
}
})()
}
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year, episodesUpdated })
}
if (itemType === 'tv_episode') {
if (!row.file_path) {
return NextResponse.json({ updated: false, reason: 'no file_path in database' })
}
const episodeDir = path.join(libraryRoot, path.dirname(row.file_path))
const baseName = path.basename(row.file_path, path.extname(row.file_path))
const nfoPath = path.join(episodeDir, `${baseName}.nfo`)
const nfo = parseEpisodeNfo(nfoPath)
if (!nfo) {
return NextResponse.json({ updated: false, reason: 'no nfo found' })
}
db.prepare(`
UPDATE media_items SET
title = @title,
plot = @plot,
metadata = @metadata
WHERE item_key = @item_key
`).run({
item_key: itemKey,
title: nfo.title ?? row.title,
plot: nfo.plot ?? null,
metadata: JSON.stringify({
...existingMeta,
episodeNumber: nfo.episode ?? existingMeta.episodeNumber ?? null,
seasonNumber: nfo.season ?? existingMeta.seasonNumber ?? null,
aired: nfo.aired ?? null,
rating: nfo.rating ?? null,
}),
})
return NextResponse.json({ updated: true, title: nfo.title })
}
return NextResponse.json({ error: 'Unhandled itemType' }, { status: 400 })
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
import { getDb } from '@/lib/db'
function extractLibraryId(itemKey: string): string | null {
const colonIdx = itemKey.indexOf(':')
if (colonIdx === -1) return null
return itemKey.slice(0, colonIdx)
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const itemKey = searchParams.get('itemKey')
if (!itemKey) {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const db = getDb()
const row = db
.prepare('SELECT user_rating FROM media_items WHERE item_key = ?')
.get(itemKey) as { user_rating: number | null } | undefined
if (!row) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
}
return NextResponse.json({ userRating: row.user_rating ?? null })
}
export async function PATCH(request: NextRequest) {
const body = await request.json()
const { itemKey, userRating } = body as { itemKey: string; userRating: number | null }
if (!itemKey) {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
if (userRating !== null && (typeof userRating !== 'number' || !Number.isInteger(userRating) || userRating < 1 || userRating > 5)) {
return NextResponse.json({ error: 'userRating must be null or an integer 15' }, { status: 400 })
}
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const db = getDb()
const result = db
.prepare('UPDATE media_items SET user_rating = ? WHERE item_key = ?')
.run(userRating, itemKey)
if (result.changes === 0) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

200
src/app/api/rename/route.ts Normal file
View File

@@ -0,0 +1,200 @@
import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
import { reKeyMediaItem } from '@/lib/tags'
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const body = await request.json()
const { libraryId, oldPath, newName, itemType } = body as {
libraryId: string
oldPath: string
newName: string
itemType: string
}
if (!libraryId || !oldPath || !newName || !itemType) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Validate newName
if (/[/\\]/.test(newName) || newName === '.' || newName === '..' || newName.startsWith('.')) {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
const root = resolveLibraryRoot(library)
let oldAbsPath: string
try {
oldAbsPath = resolveAndJail(root, oldPath)
} catch {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
}
// Compute new path by replacing the last segment
const parentDir = path.dirname(oldPath)
const newPath = parentDir === '.' ? newName : `${parentDir}/${newName}`
let newAbsPath: string
try {
newAbsPath = resolveAndJail(root, newPath)
} catch {
return NextResponse.json({ error: 'Invalid new path' }, { status: 400 })
}
// Collision check
if (fs.existsSync(newAbsPath)) {
return NextResponse.json({ error: 'A file or folder with that name already exists' }, { status: 409 })
}
// Perform filesystem rename
try {
fs.renameSync(oldAbsPath, newAbsPath)
} catch {
return NextResponse.json({ error: 'Failed to rename' }, { status: 500 })
}
// Update database records
const db = getDb()
const enc = encodeURIComponent
const oldEnc = enc(oldPath)
const newEnc = enc(newPath)
try {
db.transaction(() => {
switch (itemType) {
case 'movie': {
const oldKey = `${libraryId}:movie:${oldEnc}`
const newKey = `${libraryId}:movie:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ?, file_path = replace(file_path, ?, ?) WHERE item_key = ?')
.run(newKey, oldPath, newPath, oldKey)
reKeyMediaItem(oldKey, newKey)
break
}
case 'tv_series': {
const oldKey = `${libraryId}:tv_series:${oldEnc}`
const newKey = `${libraryId}:tv_series:${newEnc}`
// Update series
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
reKeyMediaItem(oldKey, newKey)
// Update seasons: item_key contains series id
const seasonRows = db.prepare(
"SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = 'tv_season'"
).all(`${libraryId}:tv_season:${oldEnc}:%`) as { item_key: string }[]
for (const row of seasonRows) {
const newSeasonKey = row.item_key.replace(`:tv_season:${oldEnc}:`, `:tv_season:${newEnc}:`)
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ? WHERE item_key = ?')
.run(newSeasonKey, newKey, row.item_key)
reKeyMediaItem(row.item_key, newSeasonKey)
}
// Update episodes: item_key and file_path contain series path
const epRows = db.prepare(
"SELECT item_key, file_path FROM media_items WHERE item_key LIKE ? AND item_type = 'tv_episode'"
).all(`${libraryId}:tv_episode:${oldEnc}:%`) as { item_key: string; file_path: string | null }[]
for (const row of epRows) {
const newEpKey = row.item_key.replace(`:tv_episode:${oldEnc}:`, `:tv_episode:${newEnc}:`)
// Find new parent_key from the episode's season portion
const newParentKey = row.item_key
.replace(`:tv_episode:${oldEnc}:`, `:tv_season:${newEnc}:`)
.split(':')
.slice(0, -1) // Remove episode id portion — parent is season
// Actually, parent_key is the season key. We need to reconstruct it.
// Episode key format: libraryId:tv_episode:seriesId:seasonId:episodeId
// Season key format: libraryId:tv_season:seriesId:seasonId
const parts = newEpKey.split(':')
// parts: [libraryId, 'tv_episode', seriesEnc, seasonEnc, episodeEnc]
const seasonKey = `${parts[0]}:tv_season:${parts[2]}:${parts[3]}`
const newFilePath = row.file_path ? row.file_path.replace(oldPath, newPath) : null
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ?, file_path = ? WHERE item_key = ?')
.run(newEpKey, seasonKey, newFilePath, row.item_key)
reKeyMediaItem(row.item_key, newEpKey)
}
break
}
case 'tv_episode': {
const oldKey = `${libraryId}:tv_episode:${oldEnc}`
const newKey = `${libraryId}:tv_episode:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
.run(newKey, newPath, oldKey)
reKeyMediaItem(oldKey, newKey)
break
}
case 'game': {
const oldKey = `${libraryId}:game:${oldEnc}`
const newKey = `${libraryId}:game:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
reKeyMediaItem(oldKey, newKey)
break
}
case 'game_series': {
const oldKey = `${libraryId}:game_series:${oldEnc}`
const newKey = `${libraryId}:game_series:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
reKeyMediaItem(oldKey, newKey)
// Update child games
const gameRows = db.prepare(
"SELECT item_key FROM media_items WHERE parent_key = ? AND item_type = 'game'"
).all(oldKey) as { item_key: string }[]
for (const row of gameRows) {
const newGameKey = row.item_key.replace(`:game:${oldEnc}`, `:game:${newEnc}`)
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ? WHERE item_key = ?')
.run(newGameKey, newKey, row.item_key)
reKeyMediaItem(row.item_key, newGameKey)
}
break
}
case 'mixed_file': {
const oldKey = `${libraryId}:mixed_file:${oldEnc}`
const newKey = `${libraryId}:mixed_file:${newEnc}`
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
.run(newKey, newPath, oldKey)
reKeyMediaItem(oldKey, newKey)
// If directory, update all children
const childRows = db.prepare(
"SELECT item_key, file_path FROM media_items WHERE item_key LIKE ?"
).all(`${libraryId}:mixed_file:${enc(oldPath + '/')}%`) as { item_key: string; file_path: string | null }[]
for (const row of childRows) {
const newChildKey = row.item_key.replace(
`mixed_file:${enc(oldPath + '/')}`,
`mixed_file:${enc(newPath + '/')}`
)
const newChildPath = row.file_path ? row.file_path.replace(oldPath + '/', newPath + '/') : null
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
.run(newChildKey, newChildPath, row.item_key)
reKeyMediaItem(row.item_key, newChildKey)
}
break
}
}
})()
} catch (err) {
// Attempt to rollback filesystem rename on DB failure
try { fs.renameSync(newAbsPath, oldAbsPath) } catch { /* best effort */ }
return NextResponse.json({ error: 'Database update failed' }, { status: 500 })
}
return NextResponse.json({ newName, newPath })
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import cron from 'node-cron'
import { requireAdmin } from '@/lib/auth'
import { getScanConfig, updateScanConfig } from '@/lib/app-settings'
import { restartScheduler } from '@/lib/scheduler'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { schedule, enabled } = getScanConfig()
return NextResponse.json({ schedule, enabled })
}
export async function PUT(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: { schedule?: string; enabled?: boolean }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { schedule, enabled } = body
if (typeof schedule !== 'string' || !schedule.trim()) {
return NextResponse.json({ error: 'schedule is required' }, { status: 400 })
}
if (typeof enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 })
}
if (!cron.validate(schedule)) {
return NextResponse.json({ error: 'Invalid cron expression' }, { status: 400 })
}
updateScanConfig(schedule, enabled)
restartScheduler()
return NextResponse.json({ schedule, enabled })
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary } from '@/lib/libraries'
import { isScanRunning, runSingleLibraryScan } from '@/lib/scanner'
import { requireAdmin } from '@/lib/auth'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const library = getLibrary(id)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (isScanRunning()) {
return NextResponse.json({ error: 'Scan already in progress' }, { status: 409 })
}
// Fire-and-forget
void runSingleLibraryScan(library)
return new NextResponse(null, { status: 202 })
}

32
src/app/api/scan/route.ts Normal file
View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { isScanRunning, runFullScan } from '@/lib/scanner'
import { getScanConfig } from '@/lib/app-settings'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const config = getScanConfig()
return NextResponse.json({
isRunning: isScanRunning(),
lastScanAt: config.lastScanAt,
schedule: config.schedule,
enabled: config.enabled,
})
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
if (isScanRunning()) {
return NextResponse.json({ started: false, reason: 'already running' }, { status: 409 })
}
// Fire-and-forget — do not await
runFullScan().catch((err) => console.error('[api/scan] Scan error:', err))
return NextResponse.json({ started: true }, { status: 202 })
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getUserSettings, updateUserSettings } from '@/lib/settings'
import type { UserSettings } from '@/types'
export async function GET(req: NextRequest) {
const auth = await requireAuth(req)
if (auth instanceof NextResponse) return auth
const settings = getUserSettings(auth.session.userId)
return NextResponse.json(settings)
}
export async function PUT(req: NextRequest) {
const auth = await requireAuth(req)
if (auth instanceof NextResponse) return auth
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const s = body as Record<string, unknown>
const boolFields: (keyof UserSettings)[] = [
'mixedAutoplay', 'mixedLoop', 'mixedMuted',
'moviesAutoplay', 'moviesLoop', 'moviesMuted',
'tvAutoplay', 'tvLoop', 'tvMuted',
]
for (const field of boolFields) {
if (typeof s[field] !== 'boolean') {
return NextResponse.json({ error: `Invalid value for ${field}` }, { status: 400 })
}
}
updateUserSettings(auth.session.userId, s as unknown as UserSettings)
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { deleteTagMapping } from '@/lib/comic-metadata'
import { requireAdmin } from '@/lib/auth'
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
try {
deleteTagMapping(id)
return new NextResponse(null, { status: 204 })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete mapping'
return NextResponse.json({ error: message }, { status: 404 })
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { getTagMappingsForLibrary, createTagMapping } from '@/lib/comic-metadata'
import { requireAdmin } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const libraryId = request.nextUrl.searchParams.get('libraryId')
if (!libraryId) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const mappings = getTagMappingsForLibrary(libraryId)
return NextResponse.json(mappings)
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: { libraryId?: string; importedTagName?: string; tagId?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { libraryId, importedTagName, tagId } = body
if (!libraryId || !importedTagName || !tagId) {
return NextResponse.json(
{ error: 'libraryId, importedTagName, and tagId are required' },
{ status: 400 }
)
}
try {
const mapping = createTagMapping(libraryId, importedTagName, tagId)
return NextResponse.json(mapping, { status: 201 })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create mapping'
return NextResponse.json({ error: message }, { status: 400 })
}
}

View File

@@ -1,14 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
function extractLibraryId(itemKey: string): string | null {
const colonIdx = itemKey.indexOf(':')
if (colonIdx === -1) return null
return itemKey.slice(0, colonIdx)
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const mediaKey = searchParams.get('mediaKey')
if (!mediaKey) {
return NextResponse.json({ error: 'mediaKey is required' }, { status: 400 })
const itemKey = searchParams.get('itemKey')
if (!itemKey) {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
return NextResponse.json(getResolvedTagsForItem(mediaKey))
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
return NextResponse.json(getResolvedTagsForItem(itemKey))
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
}
@@ -16,11 +30,18 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const { mediaKey, tagId } = await request.json()
if (!mediaKey || !tagId) {
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 })
const { itemKey, tagId } = await request.json()
if (!itemKey || !tagId) {
return NextResponse.json({ error: 'itemKey and tagId are required' }, { status: 400 })
}
addTagToItem(mediaKey, tagId)
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
addTagToItem(itemKey, tagId)
return new NextResponse(null, { status: 204 })
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
@@ -30,12 +51,19 @@ export async function POST(request: NextRequest) {
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const mediaKey = searchParams.get('mediaKey')
const itemKey = searchParams.get('itemKey')
const tagId = searchParams.get('tagId')
if (!mediaKey || !tagId) {
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 })
if (!itemKey || !tagId) {
return NextResponse.json({ error: 'itemKey and tagId are required' }, { status: 400 })
}
removeTagFromItem(mediaKey, tagId)
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
removeTagFromItem(itemKey, tagId)
return new NextResponse(null, { status: 204 })
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 })

View File

@@ -1,15 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags'
import { updateCategory, deleteCategory, deleteCategoryForce, getTags, getCategories, mergeCategories } from '@/lib/tags'
import { requireAdmin } from '@/lib/auth'
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
try {
const { id } = await params
const { name } = await request.json()
const { name, merge } = await request.json()
try {
const category = updateCategory(id, name)
return NextResponse.json(category)
} catch (err) {
const msg = (err as Error).message
if (!msg.includes('already exists')) throw err
// A category with this name already exists — find it
const trimmed = (name as string).trim()
const target = getCategories().find((c) => c.name.toLowerCase() === trimmed.toLowerCase())
if (!target) throw err
if (merge) {
mergeCategories(id, target.id)
return NextResponse.json(target)
}
return NextResponse.json(
{ error: msg, conflict: true, targetCategoryId: target.id },
{ status: 409 }
)
}
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
}
@@ -19,6 +44,9 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
try {
const { id } = await params
const { searchParams } = new URL(request.url)

View File

@@ -1,7 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCategories, addCategory } from '@/lib/tags'
import { requireAuth, requireAdmin } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = await requireAuth(request)
if (auth instanceof NextResponse) return auth
export async function GET() {
try {
return NextResponse.json(getCategories())
} catch (err) {
@@ -10,6 +14,9 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
try {
const { name } = await request.json()
const category = addCategory(name)

View File

@@ -1,10 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { updateTag, deleteTag } from '@/lib/tags'
import { requireAdmin } from '@/lib/auth'
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
try {
const { id } = await params
const { name } = await request.json()
@@ -16,9 +20,12 @@ export async function PATCH(
}
export async function DELETE(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
try {
const { id } = await params
deleteTag(id)

View File

@@ -1,17 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { getTags, addTag } from '@/lib/tags'
import { getTags, getTagsSortedByUsage, addTag } from '@/lib/tags'
import { requireAuth, requireAdmin } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = await requireAuth(request)
if (auth instanceof NextResponse) return auth
try {
const { searchParams } = new URL(request.url)
const categoryId = searchParams.get('categoryId') ?? undefined
return NextResponse.json(getTags(categoryId))
const sort = searchParams.get('sort')
return NextResponse.json(
sort === 'usage' ? getTagsSortedByUsage(categoryId) : getTags(categoryId)
)
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
try {
const { name, categoryId } = await request.json()
const tag = addTag(name, categoryId)

View File

@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { getTagAssignmentsForLibrary } from '@/lib/tags'
import { requireLibraryAccess } from '@/lib/auth'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const libraryId = searchParams.get('libraryId')
if (!libraryId) return NextResponse.json({ error: 'libraryId required' }, { status: 400 })
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
return NextResponse.json(getTagAssignmentsForLibrary(libraryId))
}

View File

@@ -1,16 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import fsPromises from 'fs/promises'
import path from 'path'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { getThumbnailPath } from '@/lib/thumbnails'
import { getThumbnailPath, getCbzThumbnailPath } from '@/lib/thumbnails'
import { requireLibraryAccess } from '@/lib/auth'
import { isCorruptZipError } from '@/lib/zip-utils'
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
function getMediaType(filePath: string): 'image' | 'video' | null {
function getMediaType(filePath: string): 'image' | 'video' | 'cbz' | null {
const ext = path.extname(filePath).toLowerCase()
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
if (VIDEO_EXTENSIONS.has(ext)) return 'video'
if (ext === '.cbz') return 'cbz'
return null
}
@@ -23,6 +27,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
@@ -39,11 +46,13 @@ export async function GET(request: NextRequest) {
const mediaType = getMediaType(filePath)
if (!mediaType) {
return NextResponse.json({ error: 'Thumbnails are only supported for image and video files' }, { status: 400 })
return NextResponse.json({ error: 'Thumbnails are only supported for image, video, and CBZ files' }, { status: 400 })
}
try {
const thumbnailPath = await getThumbnailPath(filePath, libraryId, mediaType)
const thumbnailPath = mediaType === 'cbz'
? await getCbzThumbnailPath(filePath, libraryId)
: await getThumbnailPath(filePath, libraryId, mediaType)
const stat = fs.statSync(thumbnailPath)
const stream = fs.createReadStream(thumbnailPath)
@@ -56,7 +65,30 @@ export async function GET(request: NextRequest) {
},
})
} catch (err) {
if (isCorruptZipError(err)) {
// Move the corrupt archive to the library's .trash folder so it is excluded
// from future scans and hidden from the UI.
const trashDir = path.join(root, '.trash')
const filename = path.basename(filePath)
let dest = path.join(trashDir, filename)
fsPromises.mkdir(trashDir, { recursive: true })
.then(async () => {
if (fs.existsSync(dest)) {
const ext = path.extname(filename)
dest = path.join(trashDir, `${path.basename(filename, ext)}_${Date.now()}${ext}`)
}
await fsPromises.rename(filePath, dest).catch(async (e: NodeJS.ErrnoException) => {
if (e.code === 'EXDEV') {
await fsPromises.copyFile(filePath, dest)
await fsPromises.unlink(filePath)
} else throw e
})
console.log(`[thumbnail] Moved corrupt archive to trash: ${path.relative(root, filePath)}`)
})
.catch((e) => console.warn(`[thumbnail] Could not move corrupt archive to trash:`, e))
} else {
console.error(`Thumbnail generation failed for ${filePath}:`, err)
}
return new NextResponse(null, { status: 404 })
}
}

114
src/app/api/tv/route.ts Normal file
View File

@@ -0,0 +1,114 @@
import fs from 'fs'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { tvSeriesFromDb, tvSeasonsFromDb, tvEpisodesFromDb } from '@/lib/tv'
import { removeAllAssignmentsForItem } from '@/lib/tags'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const seriesId = searchParams.get('seriesId')
const seasonId = searchParams.get('seasonId')
if (!libraryId) {
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'tv') {
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
}
if (seriesId && seasonId) {
return NextResponse.json(tvEpisodesFromDb(libraryId, seriesId, seasonId))
}
if (seriesId) {
return NextResponse.json(tvSeasonsFromDb(libraryId, seriesId))
}
return NextResponse.json(tvSeriesFromDb(libraryId))
}
export async function DELETE(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const seriesId = searchParams.get('seriesId')
const episodeKey = searchParams.get('episodeKey')
if (!libraryId || !seriesId) {
return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'tv') {
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
}
const root = resolveLibraryRoot(library)
// Episode-level delete
if (episodeKey) {
const db = getDb()
const row = db.prepare('SELECT file_path FROM media_items WHERE item_key = ?').get(episodeKey) as { file_path: string | null } | undefined
if (!row?.file_path) {
return NextResponse.json({ error: 'Episode not found' }, { status: 404 })
}
let episodePath: string
try {
episodePath = resolveAndJail(root, row.file_path)
} catch {
return NextResponse.json({ error: 'Invalid episode path' }, { status: 400 })
}
try {
fs.unlinkSync(episodePath)
// Also remove sidecar NFO if it exists
const nfoPath = episodePath.replace(path.extname(episodePath), '.nfo')
if (fs.existsSync(nfoPath)) fs.unlinkSync(nfoPath)
} catch {
return NextResponse.json({ error: 'Failed to delete episode file' }, { status: 500 })
}
removeAllAssignmentsForItem(episodeKey)
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(episodeKey)
return new NextResponse(null, { status: 204 })
}
// Series-level delete
const dirName = decodeURIComponent(seriesId)
let seriesDir: string
try {
seriesDir = resolveAndJail(root, dirName)
} catch {
return NextResponse.json({ error: 'Invalid series path' }, { status: 400 })
}
try {
fs.rmSync(seriesDir, { recursive: true, force: true })
} catch {
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
}
removeAllAssignmentsForItem(`${libraryId}:tv_series:${seriesId}`)
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSeriesEpisodeTagMap } from '@/lib/tags'
import { requireLibraryAccess } from '@/lib/auth'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
if (!libraryId) return NextResponse.json({ error: 'libraryId required' }, { status: 400 })
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
return NextResponse.json(getSeriesEpisodeTagMap(libraryId))
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getUserById, getLibraryPermissions, setLibraryPermissions, type LibraryPermission } from '@/lib/users'
import { getLibraries } from '@/lib/libraries'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const user = getUserById(id)
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const permissions = getLibraryPermissions(id)
return NextResponse.json({ permissions })
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const user = getUserById(id)
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
let body: { permissions?: unknown }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
if (!Array.isArray(body.permissions)) {
return NextResponse.json({ error: 'permissions must be an array' }, { status: 400 })
}
const validAccessLevels = new Set(['read', 'write'])
for (const item of body.permissions) {
if (
typeof item !== 'object' ||
item === null ||
typeof (item as Record<string, unknown>).libraryId !== 'string' ||
!validAccessLevels.has((item as Record<string, unknown>).accessLevel as string)
) {
return NextResponse.json(
{ error: 'Each permission must have libraryId (string) and accessLevel ("read" | "write")' },
{ status: 400 }
)
}
}
const permissions = body.permissions as LibraryPermission[]
const allLibraries = getLibraries()
const validIds = new Set(allLibraries.map((l) => l.id))
const invalid = permissions.filter((p) => !validIds.has(p.libraryId)).map((p) => p.libraryId)
if (invalid.length > 0) {
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
}
setLibraryPermissions(id, permissions)
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getUserById, deleteUser, listUsers } from '@/lib/users'
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { session } = auth
const { id } = await params
if (id === session.userId) {
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 409 })
}
const target = getUserById(id)
if (!target) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
if (target.role === 'admin') {
const admins = listUsers().filter((u) => u.role === 'admin')
if (admins.length <= 1) {
return NextResponse.json({ error: 'Cannot delete the last admin account' }, { status: 409 })
}
}
deleteUser(id)
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { listUsers } from '@/lib/users'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
return NextResponse.json(listUsers())
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="-5.5 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>android</title>
<path d="M14.563 4.344l-1.219 1.719c1.906 0.906 3.281 2.594 3.438 4.563h-13c0.156-1.969 1.5-3.656 3.406-4.563l-1.219-1.719c-0.063-0.125-0.031-0.25 0.063-0.313s0.219-0.031 0.313 0.063l1.25 1.813c0.813-0.313 1.719-0.5 2.688-0.5s1.844 0.188 2.688 0.5l1.25-1.813c0.063-0.094 0.188-0.125 0.281-0.063s0.125 0.188 0.063 0.313zM7.531 8.813c0.406 0 0.719-0.313 0.719-0.719 0-0.375-0.313-0.719-0.719-0.719-0.375 0-0.719 0.344-0.719 0.719 0 0.406 0.344 0.719 0.719 0.719zM13.094 8.813c0.406 0 0.719-0.313 0.719-0.719 0-0.375-0.313-0.719-0.719-0.719-0.375 0-0.719 0.344-0.719 0.719 0 0.406 0.344 0.719 0.719 0.719zM0 18.781v-5.781c0-0.813 0.625-1.5 1.469-1.5 0.813 0 1.438 0.688 1.438 1.5v5.781c0 0.844-0.625 1.5-1.438 1.5-0.844 0-1.469-0.656-1.469-1.5zM17.594 18.781v-5.781c0-0.813 0.656-1.5 1.469-1.5s1.469 0.688 1.469 1.5v5.781c0 0.844-0.656 1.5-1.469 1.5s-1.469-0.656-1.469-1.5zM3.813 22.125v-10.594h13v10.594c0 0.625-0.531 1.156-1.156 1.156h-1.281v3.281c0 0.813-0.656 1.469-1.469 1.469s-1.469-0.656-1.469-1.469v-3.281h-2.281v3.281c0 0.813-0.625 1.469-1.438 1.469-0.844 0-1.469-0.656-1.469-1.469v-3.281h-1.313c-0.594 0-1.125-0.531-1.125-1.156z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

6
src/app/icons/linux.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 76 76" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" enable-background="new 0 0 76.00 76.00" xml:space="preserve">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 35.625,29.6875C 36.4995,29.6875 37.2083,30.3964 37.2083,31.2708C 37.2083,32.1453 36.4994,32.8542 35.625,32.8542C 34.7505,32.8542 34.0417,32.1453 34.0417,31.2708C 34.0417,30.3964 34.7505,29.6875 35.625,29.6875 Z M 40.7708,29.6875C 41.6453,29.6875 42.3542,30.3964 42.3542,31.2708C 42.3542,32.1453 41.6453,32.8542 40.7708,32.8542C 39.8964,32.8542 39.1875,32.1453 39.1875,31.2708C 39.1875,30.3964 39.8964,29.6875 40.7708,29.6875 Z M 25.6695,50.3757C 24.9442,48.0415 24.5417,45.4621 24.5417,42.75L 24.5568,41.8418C 22.3238,43.0668 19.8176,44.3333 19,44.3333C 16.8873,44.3333 20.8257,39.4499 25.9794,34.1962C 26.4655,32.8374 27.0638,31.5722 27.7572,30.4249C 28.2641,24.0121 32.6565,19 38,19C 43.3435,19 47.7358,24.0121 48.2428,30.4249C 48.9362,31.5722 49.5345,32.8374 50.0206,34.1962C 55.1743,39.4499 59.1127,44.3333 57,44.3333C 56.1824,44.3333 53.6762,43.0669 51.4432,41.8418L 51.4583,42.75C 51.4583,45.4621 51.0558,48.0415 50.3305,50.3757L 48.2917,49.875C 48.2917,43.7841 45.5317,38.5857 41.649,36.5467L 38,42.75L 34.3514,36.5475C 30.4685,38.59 27.7084,43.8045 27.7085,49.9664L 25.6695,50.3757 Z M 34.0416,26.125C 31.8555,26.125 30.0833,28.2517 30.0833,30.875C 30.0833,33.4984 31.8555,35.625 34.0416,35.625C 36.2278,35.625 38,33.4984 38,30.875C 38,28.2517 36.2278,26.125 34.0416,26.125 Z M 38,30.875C 38,32.6239 39.7722,34.4375 41.9583,34.4375C 44.1444,34.4375 45.9166,32.6239 45.9166,30.875C 45.9166,29.1261 44.1444,27.3125 41.9583,27.3125C 39.7722,27.3125 38,29.1261 38,30.875 Z M 30.0833,50.6667C 33.1473,50.6667 35.7032,52.0266 36.29,53.8333L 36.8125,53.8333L 36.8125,55.4167L 36.29,55.4167C 35.7032,57.2234 33.1473,58.5833 30.0833,58.5833C 26.5855,58.5833 23.75,56.8111 23.75,54.625C 23.75,52.4389 26.5855,50.6667 30.0833,50.6667 Z M 45.9166,50.6667C 49.4144,50.6667 52.25,52.4389 52.25,54.625C 52.25,56.8111 49.4144,58.5833 45.9166,58.5833C 42.8526,58.5833 40.2968,57.2234 39.71,55.4167L 39.1875,55.4167L 39.1875,53.8333L 39.71,53.8333C 40.2968,52.0266 42.8526,50.6667 45.9166,50.6667 Z "/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

19
src/app/icons/mac.svg Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-1.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>apple [#173]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-102.000000, -7439.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M57.5708873,7282.19296 C58.2999598,7281.34797 58.7914012,7280.17098 58.6569121,7279 C57.6062792,7279.04 56.3352055,7279.67099 55.5818643,7280.51498 C54.905374,7281.26397 54.3148354,7282.46095 54.4735932,7283.60894 C55.6455696,7283.69593 56.8418148,7283.03894 57.5708873,7282.19296 M60.1989864,7289.62485 C60.2283111,7292.65181 62.9696641,7293.65879 63,7293.67179 C62.9777537,7293.74279 62.562152,7295.10677 61.5560117,7296.51675 C60.6853718,7297.73474 59.7823735,7298.94772 58.3596204,7298.97372 C56.9621472,7298.99872 56.5121648,7298.17973 54.9134635,7298.17973 C53.3157735,7298.17973 52.8162425,7298.94772 51.4935978,7298.99872 C50.1203933,7299.04772 49.0738052,7297.68074 48.197098,7296.46676 C46.4032359,7293.98379 45.0330649,7289.44985 46.8734421,7286.3899 C47.7875635,7284.87092 49.4206455,7283.90793 51.1942837,7283.88393 C52.5422083,7283.85893 53.8153044,7284.75292 54.6394294,7284.75292 C55.4635543,7284.75292 57.0106846,7283.67793 58.6366882,7283.83593 C59.3172232,7283.86293 61.2283842,7284.09893 62.4549652,7285.8199 C62.355868,7285.8789 60.1747177,7287.09489 60.1989864,7289.62485" id="apple-[#173]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

19
src/app/icons/windows.svg Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>windows [#174]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-60.000000, -7439.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M13.1458647,7289.43426 C13.1508772,7291.43316 13.1568922,7294.82929 13.1619048,7297.46884 C16.7759398,7297.95757 20.3899749,7298.4613 23.997995,7299 C23.997995,7295.84873 24.002005,7292.71146 23.997995,7289.71311 C20.3809524,7289.71311 16.7649123,7289.43426 13.1458647,7289.43426 M4,7289.43526 L4,7296.22153 C6.72581454,7296.58933 9.45162907,7296.94113 12.1724311,7297.34291 C12.1774436,7294.71736 12.1704261,7292.0908 12.1704261,7289.46524 C9.44661654,7289.47024 6.72380952,7289.42627 4,7289.43526 M4,7281.84344 L4,7288.61071 C6.72581454,7288.61771 9.45162907,7288.57673 12.1774436,7288.57973 C12.1754386,7285.96017 12.1754386,7283.34361 12.1724311,7280.72405 C9.44461153,7281.06486 6.71679198,7281.42567 4,7281.84344 M24,7288.47179 C20.3879699,7288.48578 16.7759398,7288.54075 13.1619048,7288.55175 C13.1598997,7285.88921 13.1598997,7283.22967 13.1619048,7280.56914 C16.7689223,7280.01844 20.3839599,7279.50072 23.997995,7279 C24,7282.15826 23.997995,7285.31353 24,7288.47179" id="windows-[#174]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import NavLink from '@/components/NavLink'
import { getServerSession } from '@/lib/auth'
import HeaderNav from '@/components/HeaderNav'
import './globals.css'
export const metadata: Metadata = {
@@ -7,11 +8,13 @@ export const metadata: Metadata = {
description: 'Your personal media library',
}
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
return (
<html lang="en">
<body className="min-h-screen">
@@ -22,7 +25,9 @@ export default function RootLayout({
MediaLore
</a>
<nav className="flex items-center gap-1">
<NavLink href="/manage">Manage Libraries</NavLink>
{session.userId && (
<HeaderNav username={session.username} isAdmin={session.role === 'admin'} />
)}
</nav>
</div>
</header>

View File

@@ -1,7 +1,13 @@
import { getLibrary } from '@/lib/libraries'
import { notFound } from 'next/navigation'
import { notFound, redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { getLibraryAccessLevel } from '@/lib/users'
import ComicsView from '@/components/comics/ComicsView'
import GamesView from '@/components/games/GamesView'
import MixedView from '@/components/mixed/MixedView'
import MoviesView from '@/components/movies/MoviesView'
import TvView from '@/components/tv/TvView'
import ScanLibraryButton from '@/components/ScanLibraryButton'
interface Props {
params: Promise<{ id: string }>
@@ -12,11 +18,22 @@ export default async function LibraryPage({ params, searchParams }: Props) {
const { id } = await params
const { path: subpath } = await searchParams
const session = await getServerSession()
if (!session.userId) redirect('/login')
const library = getLibrary(id)
if (!library) notFound()
let readOnly = false
if (session.role !== 'admin') {
const accessLevel = getLibraryAccessLevel(session.userId, id)
if (!accessLevel) notFound()
readOnly = accessLevel === 'read'
}
return (
<div>
{library.type !== 'mixed' && (
<div className="flex items-center gap-2 mb-6">
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
Libraries
@@ -25,10 +42,24 @@ export default async function LibraryPage({ params, searchParams }: Props) {
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{library.name}
</span>
{session.role === 'admin' && (
<div className="ml-auto">
<ScanLibraryButton libraryId={id} />
</div>
)}
</div>
)}
{library.type === 'mixed' && session.role === 'admin' && (
<div className="flex justify-end mb-2">
<ScanLibraryButton libraryId={id} />
</div>
)}
{library.type === 'games' && <GamesView libraryId={id} />}
{library.type === 'mixed' && <MixedView libraryId={id} initialPath={subpath ?? ''} />}
{library.type === 'comics' && <ComicsView libraryId={id} readOnly={readOnly} />}
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
{library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />}
</div>
)
}

158
src/app/login/LoginForm.tsx Normal file
View File

@@ -0,0 +1,158 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
interface Props {
isFirstRun: boolean
}
export default function LoginForm({ isFirstRun }: Props) {
const router = useRouter()
const searchParams = useSearchParams()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const from = searchParams.get('from') ?? '/'
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (isFirstRun && password !== confirmPassword) {
setError('Passwords do not match')
return
}
setLoading(true)
try {
if (isFirstRun) {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role: 'admin' }),
})
if (!res.ok) {
const data = await res.json()
setError(data.error ?? 'Registration failed')
return
}
}
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!loginRes.ok) {
const data = await loginRes.json()
setError(data.error ?? 'Login failed')
return
}
router.push(from)
router.refresh()
} finally {
setLoading(false)
}
}
return (
<div className="w-full max-w-sm px-6">
<div className="mb-8 text-center">
<span style={{ color: 'var(--accent)', fontSize: '2rem' }}></span>
<h1 className="mt-2 text-2xl font-semibold" style={{ color: 'var(--text-primary)' }}>
MediaLore
</h1>
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
{isFirstRun ? 'Create your admin account to get started' : 'Sign in to your account'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
autoComplete="username"
className="w-full px-3 py-2 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--surface)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete={isFirstRun ? 'new-password' : 'current-password'}
className="w-full px-3 py-2 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--surface)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
/>
</div>
{isFirstRun && (
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
className="w-full px-3 py-2 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--surface)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
/>
</div>
)}
{error && (
<p className="text-sm" style={{ color: 'var(--error, #ef4444)' }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 rounded-lg text-sm font-medium transition-opacity"
style={{
backgroundColor: 'var(--accent)',
color: 'var(--background)',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Please wait…' : isFirstRun ? 'Create Account' : 'Sign In'}
</button>
</form>
</div>
)
}

16
src/app/login/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { Metadata } from 'next'
import '../globals.css'
export const metadata: Metadata = {
title: 'MediaLore — Sign In',
}
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen flex items-center justify-center">
{children}
</body>
</html>
)
}

14
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Suspense } from 'react'
import { getUserCount } from '@/lib/users'
import LoginForm from './LoginForm'
export const dynamic = 'force-dynamic'
export default function LoginPage() {
const isFirstRun = getUserCount() === 0
return (
<Suspense>
<LoginForm isFirstRun={isFirstRun} />
</Suspense>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,12 @@
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import ManageSubNav from '@/components/ManageSubNav'
export default function ManageLayout({ children }: { children: React.ReactNode }) {
export default async function ManageLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession()
if (!session.userId) redirect('/login')
if (session.role !== 'admin') redirect('/')
return (
<div>
<ManageSubNav />

View File

@@ -1,16 +1,28 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import Image from 'next/image'
import type { Library, LibraryType } from '@/types'
const TYPE_ICONS: Record<string, string> = {
comics: '📚',
games: '🎮',
mixed: '🗂️',
movies: '🎬',
tv: '📺',
}
const TYPE_LABELS: Record<LibraryType, string> = {
comics: 'Comics / Manga',
games: 'Games',
mixed: 'Mixed Media',
movies: 'Movies',
tv: 'TV Shows',
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function ManagePage() {
function ManagePage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [loading, setLoading] = useState(true)
@@ -47,7 +59,7 @@ export default function ManagePage() {
) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{libraries.map((lib) => (
<LibraryRow key={lib.id} library={lib} onRemoved={refresh} />
<LibraryRow key={lib.id} library={lib} onRemoved={refresh} onUpdated={refresh} />
))}
</div>
)}
@@ -83,19 +95,31 @@ function Section({ title, children }: { title: string; children: React.ReactNode
// ─── Library Row ──────────────────────────────────────────────────────────────
function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () => void }) {
function LibraryRow({
library,
onRemoved,
onUpdated,
}: {
library: Library
onRemoved: () => void
onUpdated: () => void
}) {
const [confirming, setConfirming] = useState(false)
const [removing, setRemoving] = useState(false)
const [uploadingCover, setUploadingCover] = useState(false)
const [importing, setImporting] = useState<'idle' | 'running' | 'done'>('idle')
const [showBulkRename, setShowBulkRename] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [showImportWarning, setShowImportWarning] = useState(false)
const [importingMetadata, setImportingMetadata] = useState(false)
const handleRemoveClick = () => {
if (!confirming) {
setConfirming(true)
// Auto-cancel confirmation after 4s if user does nothing
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
return
}
// Second click — confirmed
if (cancelRef.current) clearTimeout(cancelRef.current)
setRemoving(true)
fetch(`/api/libraries/${encodeURIComponent(library.id)}`, { method: 'DELETE' })
@@ -103,13 +127,88 @@ function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () =>
.catch(() => setRemoving(false))
}
const handleImportMetadata = async () => {
setImportingMetadata(true)
setShowImportWarning(false)
try {
const endpoint =
library.type === 'tv'
? `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-tv`
: `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-movies`
const res = await fetch(endpoint, { method: 'POST' })
if (res.ok) {
const data = await res.json()
console.log(`[manage] Imported metadata: ${data.imported} items, skipped ${data.skipped}`)
}
} catch (err) {
console.error('[manage] Error importing metadata:', err)
} finally {
setImportingMetadata(false)
}
}
const handleCancel = () => {
if (cancelRef.current) clearTimeout(cancelRef.current)
setConfirming(false)
}
const handleCoverChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingCover(true)
const form = new FormData()
form.append('cover', file)
await fetch(`/api/library-cover/${encodeURIComponent(library.id)}`, {
method: 'POST',
body: form,
})
setUploadingCover(false)
// Reset input so the same file can be re-selected
e.target.value = ''
onUpdated()
}
const handleRemoveCover = async () => {
await fetch(`/api/library-cover/${encodeURIComponent(library.id)}`, { method: 'DELETE' })
onUpdated()
}
return (
<div className="flex items-center gap-4 py-3 first:pt-0 last:pb-0">
{/* Cover thumbnail */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingCover}
title="Click to change cover image"
className="flex-shrink-0 w-16 h-10 rounded-lg overflow-hidden relative transition-opacity disabled:opacity-50"
style={{ border: '1px solid var(--border)' }}
>
{library.coverExt ? (
<Image
src={`/api/library-cover/${library.id}`}
alt=""
fill
className="object-cover"
unoptimized
/>
) : (
<span
className="flex items-center justify-center w-full h-full text-xl"
style={{ backgroundColor: 'var(--border)' }}
>
{TYPE_ICONS[library.type] ?? '📁'}
</span>
)}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleCoverChange}
/>
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
@@ -134,6 +233,68 @@ function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () =>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{library.type === 'comics' && (
<>
<button
onClick={() => {
setImporting('running')
fetch(`/api/libraries/${encodeURIComponent(library.id)}/import-metadata`, { method: 'POST' })
.then(() => {
setImporting('done')
setTimeout(() => setImporting('idle'), 3000)
})
.catch(() => setImporting('idle'))
}}
disabled={importing === 'running'}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => {
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
</button>
<button
onClick={() => setShowBulkRename(true)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Bulk Rename
</button>
</>
)}
{(library.type === 'tv' || library.type === 'movies') && (
<button
onClick={() => setShowImportWarning(true)}
disabled={importingMetadata}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => {
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{importingMetadata ? 'Importing…' : 'Import Metadata'}
</button>
)}
{library.coverExt && (
<button
onClick={handleRemoveCover}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Remove cover
</button>
)}
{confirming && (
<button
onClick={handleCancel}
@@ -169,6 +330,216 @@ function LibraryRow({ library, onRemoved }: { library: Library; onRemoved: () =>
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
</button>
</div>
{showBulkRename && (
<BulkRenameModal libraryId={library.id} onClose={() => setShowBulkRename(false)} />
)}
{showImportWarning && (library.type === 'tv' || library.type === 'movies') && (
<ImportWarningModal
libraryType={library.type}
onConfirm={handleImportMetadata}
onCancel={() => setShowImportWarning(false)}
/>
)}
</div>
)
}
// ─── Bulk Rename Modal ────────────────────────────────────────────────────────
function BulkRenameModal({ libraryId, onClose }: { libraryId: string; onClose: () => void }) {
const [pattern, setPattern] = useState('')
const [preview, setPreview] = useState<{ itemKey: string; oldTitle: string; newTitle: string }[] | null>(null)
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<string | null>(null)
const handlePreview = async () => {
if (!pattern.trim()) return
setError(null)
setPreview(null)
setResult(null)
setLoading(true)
try {
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim(), preview: true }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Failed to preview')
} else {
setPreview(data.changes ?? [])
}
} catch {
setError('Network error')
} finally {
setLoading(false)
}
}
const handleApply = async () => {
if (!pattern.trim()) return
setError(null)
setApplying(true)
try {
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim() }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Failed to apply')
} else {
setResult(`Updated ${data.updated} title${data.updated === 1 ? '' : 's'}`)
setPreview(null)
}
} catch {
setError('Network error')
} finally {
setApplying(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
>
<div
className="w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col"
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
maxHeight: '80vh',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div>
<p className="font-medium" style={{ color: 'var(--text-primary)' }}>Bulk Rename</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
Enter a regex pattern to remove from comic titles
</p>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
</button>
</div>
{/* Body */}
<div className="px-5 py-4 overflow-y-auto flex-1">
{/* Pattern input */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handlePreview() }}
placeholder="e.g. \[English\]|\{doujin-moe\.us\}"
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none font-mono"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
autoFocus
/>
<button
onClick={handlePreview}
disabled={!pattern.trim() || loading}
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{loading ? 'Loading…' : 'Preview'}
</button>
</div>
{error && (
<p
className="text-xs mb-3 px-3 py-2 rounded-lg"
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
>
{error}
</p>
)}
{result && (
<p
className="text-xs mb-3 px-3 py-2 rounded-lg"
style={{ backgroundColor: '#14532d33', color: '#86efac' }}
>
{result}
</p>
)}
{/* Preview list */}
{preview !== null && (
preview.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No titles match this pattern.
</p>
) : (
<div>
<p className="text-xs mb-2" style={{ color: 'var(--text-secondary)' }}>
{preview.length} title{preview.length === 1 ? '' : 's'} will be updated:
</p>
<div
className="rounded-lg border divide-y overflow-hidden"
style={{ borderColor: 'var(--border)' }}
>
{preview.map((c) => (
<div key={c.itemKey} className="px-3 py-2">
<p className="text-xs line-through" style={{ color: 'var(--text-secondary)' }}>
{c.oldTitle}
</p>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
{c.newTitle}
</p>
</div>
))}
</div>
</div>
)
)}
</div>
{/* Footer */}
{preview && preview.length > 0 && (
<div
className="flex items-center justify-end gap-2 px-5 py-3 flex-shrink-0"
style={{ borderTop: '1px solid var(--border)' }}
>
<button
onClick={onClose}
className="text-xs px-3 py-2 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
<button
onClick={handleApply}
disabled={applying}
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
>
{applying ? 'Applying…' : `Apply to ${preview.length} title${preview.length === 1 ? '' : 's'}`}
</button>
</div>
)}
</div>
</div>
)
}
@@ -202,7 +573,10 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
return
}
// Success — reset form
// Success — fire scan for the new library (fire-and-forget)
void fetch(`/api/scan/${encodeURIComponent((data as { id: string }).id)}`, { method: 'POST' })
// Reset form
setName('')
setLibPath('')
setType('games')
@@ -247,8 +621,11 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
>
<option value="comics">Comics / Manga</option>
<option value="games">Games</option>
<option value="mixed">Mixed Media</option>
<option value="movies">Movies</option>
<option value="tv">TV Shows</option>
</select>
</Field>
</div>
@@ -326,3 +703,57 @@ function LoadingRows() {
</div>
)
}
function ImportWarningModal({
libraryType,
onConfirm,
onCancel,
}: {
libraryType: 'tv' | 'movies'
onConfirm: () => void
onCancel: () => void
}) {
const label = libraryType === 'tv' ? 'TV' : 'Movie'
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onCancel}
>
<div
className="w-full max-w-md rounded-2xl border p-5"
style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Import {label} Metadata
</h3>
<p className="text-sm mb-5" style={{ color: 'var(--text-secondary)' }}>
Full metadata import will refresh metadata for ALL items in this library, overwriting any
existing data. Continue?
</p>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="text-xs px-3 py-2 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="text-xs px-3 py-2 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
Import
</button>
</div>
</div>
</div>
)
}
export default ManagePage

View File

@@ -0,0 +1,287 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
interface ScanStatus {
isRunning: boolean
lastScanAt: number | null
schedule: string
enabled: boolean
}
interface ScanSettings {
schedule: string
enabled: boolean
}
function formatDate(ts: number | null): string {
if (!ts) return 'Never'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(ts))
}
export default function ScanningPage() {
const [status, setStatus] = useState<ScanStatus | null>(null)
const [settings, setSettings] = useState<ScanSettings>({ schedule: '0 * * * *', enabled: true })
const [loadingStatus, setLoadingStatus] = useState(true)
const [scanning, setScanning] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saveSuccess, setSaveSuccess] = useState(false)
const [savingSettings, setSavingSettings] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/scan')
if (!res.ok) return
const data: ScanStatus = await res.json()
setStatus(data)
setScanning(data.isRunning)
setSettings({ schedule: data.schedule, enabled: data.enabled })
} catch {
// ignore
} finally {
setLoadingStatus(false)
}
}, [])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
// Poll every 2s while a scan is in progress
useEffect(() => {
if (scanning) {
pollRef.current = setInterval(fetchStatus, 2000)
} else {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [scanning, fetchStatus])
const handleScanNow = async () => {
if (scanning) return
try {
const res = await fetch('/api/scan', { method: 'POST' })
if (res.status === 202) {
setScanning(true)
fetchStatus()
} else if (res.status === 409) {
setScanning(true)
}
} catch {
// ignore
}
}
const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault()
setSaveError(null)
setSaveSuccess(false)
setSavingSettings(true)
try {
const res = await fetch('/api/scan-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
const data = await res.json()
if (!res.ok) {
setSaveError(data.error ?? 'Failed to save settings')
} else {
setSettings(data)
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 3000)
}
} catch {
setSaveError('Network error. Please try again.')
} finally {
setSavingSettings(false)
}
}
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Library Scanning
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Scan libraries to index metadata and pre-generate thumbnails.
</p>
<Section title="Status">
{loadingStatus ? (
<LoadingRows />
) : (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{scanning ? 'Scanning…' : 'Idle'}
</span>
{scanning && (
<span
className="text-xs px-2 py-0.5 rounded-full animate-pulse"
style={{ backgroundColor: '#16a34a33', color: '#4ade80' }}
>
Running
</span>
)}
</div>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Last scan: {formatDate(status?.lastScanAt ?? null)}
</span>
</div>
<button
onClick={handleScanNow}
disabled={scanning}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!scanning) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{scanning ? 'Scanning…' : 'Scan Now'}
</button>
</div>
)}
</Section>
<Section title="Schedule">
<form onSubmit={handleSaveSettings} className="flex flex-col gap-5">
<Field label="Cron Expression">
<input
type="text"
value={settings.schedule}
onChange={(e) => setSettings((s) => ({ ...s, schedule: e.target.value }))}
placeholder="0 * * * *"
required
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
Standard 5-field cron (minute hour day month weekday). Default: <code className="font-mono">0 * * * *</code> (hourly).
</p>
</Field>
<Field label="Automatic Scanning">
<label className="flex items-center gap-3 cursor-pointer select-none">
<div
role="switch"
aria-checked={settings.enabled}
onClick={() => setSettings((s) => ({ ...s, enabled: !s.enabled }))}
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors cursor-pointer"
style={{
backgroundColor: settings.enabled ? 'var(--accent)' : 'var(--border)',
}}
>
<span
className="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform"
style={{ transform: settings.enabled ? 'translateX(18px)' : 'translateX(3px)' }}
/>
</div>
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
{settings.enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
</Field>
{saveError && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
>
{saveError}
</p>
)}
{saveSuccess && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{ backgroundColor: '#14532d33', color: '#4ade80' }}
>
Settings saved.
</p>
)}
<div>
<button
type="submit"
disabled={savingSettings}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!savingSettings) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{savingSettings ? 'Saving…' : 'Save Settings'}
</button>
</div>
</form>
</Section>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--text-secondary)' }}
>
{title}
</h2>
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{children}
</div>
)
}
function LoadingRows() {
return (
<div className="flex flex-col gap-3">
{[70, 50].map((w) => (
<div key={w} className="flex items-center gap-3">
<div
className="h-4 rounded animate-pulse"
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
/>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,865 @@
'use client'
import { memo, useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { useParams } from 'next/navigation'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
export default function TagMappingsPage() {
const params = useParams()
const libraryId = params.id as string
const [library, setLibrary] = useState<Library | null>(null)
const [importedTags, setImportedTags] = useState<ImportedTag[]>([])
const [mappings, setMappings] = useState<TagMapping[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [categories, setCategories] = useState<TagCategory[]>([])
const [loading, setLoading] = useState(true)
const [prefixMappings, setPrefixMappings] = useState<Record<string, string>>({})
const [ignoredTags, setIgnoredTags] = useState<Set<string>>(new Set())
// Load prefix mappings and ignored tags from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(`prefix-mappings-${libraryId}`)
if (stored) setPrefixMappings(JSON.parse(stored))
} catch { /* ignore */ }
try {
const stored = localStorage.getItem(`ignored-tags-${libraryId}`)
if (stored) setIgnoredTags(new Set(JSON.parse(stored)))
} catch { /* ignore */ }
}, [libraryId])
const updatePrefixMappings = useCallback((next: Record<string, string>) => {
setPrefixMappings(next)
try {
localStorage.setItem(`prefix-mappings-${libraryId}`, JSON.stringify(next))
} catch { /* ignore */ }
}, [libraryId])
const updateIgnoredTags = useCallback((next: Set<string>) => {
setIgnoredTags(next)
try {
localStorage.setItem(`ignored-tags-${libraryId}`, JSON.stringify([...next]))
} catch { /* ignore */ }
}, [libraryId])
const refresh = () => {
Promise.all([
fetch(`/api/imported-tags?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()),
fetch(`/api/tag-mappings?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()),
fetch('/api/tags/items').then((r) => r.json()),
fetch('/api/tags/categories').then((r) => r.json()),
fetch('/api/libraries').then((r) => r.json()),
])
.then(([imported, maps, tgs, cats, libs]: [ImportedTag[], TagMapping[], Tag[], TagCategory[], Library[]]) => {
setImportedTags(imported)
setMappings(maps)
setTags(tgs)
setCategories(cats)
setLibrary(libs.find((l) => l.id === libraryId) ?? null)
setLoading(false)
})
.catch(() => setLoading(false))
}
useEffect(() => {
refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryId])
const tagsByCategory = useMemo(() => (
categories
.map((cat) => ({
category: cat,
tags: tags.filter((t) => t.categoryId === cat.id),
}))
.filter((g) => g.tags.length > 0)
), [categories, tags])
const visibleTags = useMemo(() => importedTags.filter((t) => !ignoredTags.has(t.name)), [importedTags, ignoredTags])
const hiddenTags = useMemo(() => importedTags.filter((t) => ignoredTags.has(t.name)), [importedTags, ignoredTags])
const handleIgnoreTag = useCallback((name: string) => {
updateIgnoredTags(new Set([...ignoredTags, name]))
}, [ignoredTags, updateIgnoredTags])
const handleUnignoreTag = useCallback((name: string) => {
const next = new Set(ignoredTags)
next.delete(name)
updateIgnoredTags(next)
}, [ignoredTags, updateIgnoredTags])
return (
<div className="max-w-2xl">
<div className="flex items-center gap-2 mb-1">
<a
href="/manage/tags"
className="text-sm no-underline transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Tags
</a>
</div>
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Tag Mappings{library ? `${library.name}` : ''}
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Map imported tags from ComicInfo.xml files to your tag categories.
</p>
{loading ? (
<Section title="Unmapped Tags">
<LoadingRows />
</Section>
) : (
<>
<PrefixMappingsSection
categories={categories}
importedTags={importedTags}
prefixMappings={prefixMappings}
onUpdate={updatePrefixMappings}
/>
<Section title="Unmapped Tags">
{visibleTags.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
{importedTags.length === 0
? 'No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found.'
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
</p>
) : (
<VirtualizedImportedTagRows
tags={visibleTags}
libraryId={libraryId}
tagsByCategory={tagsByCategory}
categories={categories}
prefixMappings={prefixMappings}
onMapped={refresh}
onIgnore={handleIgnoreTag}
/>
)}
</Section>
{hiddenTags.length > 0 && (
<IgnoredTagsSection
tags={hiddenTags}
onUnignore={handleUnignoreTag}
/>
)}
<Section title="Saved Mappings">
{mappings.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No saved mappings yet. Map imported tags above to create persistent mappings.
</p>
) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{mappings.map((m) => (
<MappingRow key={m.id} mapping={m} onDeleted={refresh} />
))}
</div>
)}
</Section>
</>
)}
</div>
)
}
// ─── Prefix Mappings Section ──────────────────────────────────────────────────
function PrefixMappingsSection({
categories,
importedTags,
prefixMappings,
onUpdate,
}: {
categories: TagCategory[]
importedTags: ImportedTag[]
prefixMappings: Record<string, string>
onUpdate: (next: Record<string, string>) => void
}) {
const [newPrefix, setNewPrefix] = useState('')
const [newCategoryId, setNewCategoryId] = useState('')
// Detect prefixes from imported tags that aren't yet mapped
const detectedPrefixes = Array.from(
new Set(
importedTags
.map((t) => {
const idx = t.name.indexOf(': ')
return idx > 0 ? t.name.slice(0, idx).trim().toLowerCase() : null
})
.filter((p): p is string => p !== null)
)
).filter((p) => !(p in prefixMappings)).sort()
const catMap = new Map(categories.map((c) => [c.id, c.name]))
const entries = Object.entries(prefixMappings)
const handleAdd = () => {
const key = newPrefix.trim().toLowerCase()
if (!key || !newCategoryId) return
onUpdate({ ...prefixMappings, [key]: newCategoryId })
setNewPrefix('')
setNewCategoryId('')
}
const handleRemove = (key: string) => {
const next = { ...prefixMappings }
delete next[key]
onUpdate(next)
}
return (
<Section title="Prefix Mappings">
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
Map tag prefixes (e.g. &quot;language&quot; in &quot;language: english&quot;) to categories.
When creating a new tag, the category and name will auto-fill.
</p>
{/* Existing mappings */}
{entries.length > 0 && (
<div className="divide-y mb-3" style={{ borderColor: 'var(--border)' }}>
{entries.map(([prefix, catId]) => (
<div key={prefix} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-mono"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{prefix}:
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}></span>
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>
{catMap.get(catId) ?? catId}
</span>
<div className="flex-1" />
<button
onClick={() => handleRemove(prefix)}
className="text-xs px-2 py-1 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
Remove
</button>
</div>
))}
</div>
)}
{/* Add row */}
<div className="flex items-center gap-2">
<input
type="text"
value={newPrefix}
onChange={(e) => setNewPrefix(e.target.value)}
placeholder="prefix"
className="rounded-lg px-2 py-1.5 text-xs font-mono outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 100,
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd() }}
/>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}></span>
<select
value={newCategoryId}
onChange={(e) => setNewCategoryId(e.target.value)}
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minWidth: 130,
}}
>
<option value="">Category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<button
onClick={handleAdd}
disabled={!newPrefix.trim() || !newCategoryId}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
Add
</button>
</div>
{/* Suggestions */}
{detectedPrefixes.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Detected:</span>
{detectedPrefixes.map((p) => (
<button
key={p}
onClick={() => setNewPrefix(p)}
className="text-xs px-2 py-0.5 rounded-full transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
{p}
</button>
))}
</div>
)}
</Section>
)
}
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
function VirtualizedImportedTagRows({
tags,
libraryId,
tagsByCategory,
categories,
prefixMappings,
onMapped,
onIgnore,
}: {
tags: ImportedTag[]
libraryId: string
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
categories: TagCategory[]
prefixMappings: Record<string, string>
onMapped: () => void
onIgnore: (name: string) => void
}) {
const parentRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: tags.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 8,
})
return (
<div ref={parentRef} className="max-h-[560px] overflow-auto">
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((row) => {
const importedTag = tags[row.index]
return (
<div
key={importedTag.name}
ref={rowVirtualizer.measureElement}
data-index={row.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${row.start}px)`,
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
}}
>
<ImportedTagRow
importedTag={importedTag}
libraryId={libraryId}
tagsByCategory={tagsByCategory}
categories={categories}
prefixMappings={prefixMappings}
onMapped={onMapped}
onIgnore={() => onIgnore(importedTag.name)}
/>
</div>
)
})}
</div>
</div>
)
}
const ImportedTagRow = memo(function ImportedTagRow({
importedTag,
libraryId,
tagsByCategory,
categories,
prefixMappings,
onMapped,
onIgnore,
}: {
importedTag: ImportedTag
libraryId: string
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
categories: TagCategory[]
prefixMappings: Record<string, string>
onMapped: () => void
onIgnore: () => void
}) {
// Auto-match: if prefix mapping exists, find a tag in that category matching the stripped name
const autoMatchedTagId = useMemo(() => {
const colonIdx = importedTag.name.indexOf(': ')
if (colonIdx <= 0) return ''
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
const mappedCategoryId = prefixMappings[prefix]
if (!mappedCategoryId) return ''
const strippedName = importedTag.name.slice(colonIdx + 2).trim().toLowerCase()
const group = tagsByCategory.find((g) => g.category.id === mappedCategoryId)
const match = group?.tags.find((t) => t.name.toLowerCase() === strippedName)
return match?.id ?? ''
}, [importedTag.name, prefixMappings, tagsByCategory])
const [selectedTagId, setSelectedTagId] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [newTagName, setNewTagName] = useState(importedTag.name)
const [newTagCategoryId, setNewTagCategoryId] = useState('')
const [creatingTag, setCreatingTag] = useState(false)
// Apply auto-match when it changes (e.g. prefix mappings updated)
useEffect(() => {
if (autoMatchedTagId) setSelectedTagId(autoMatchedTagId)
}, [autoMatchedTagId])
const startCreating = () => {
// Apply prefix mapping defaults if the imported tag has a colon prefix
const colonIdx = importedTag.name.indexOf(': ')
if (colonIdx > 0) {
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
const mappedCategoryId = prefixMappings[prefix]
if (mappedCategoryId) {
setNewTagCategoryId(mappedCategoryId)
setNewTagName(importedTag.name.slice(colonIdx + 2).trim())
setCreating(true)
return
}
}
setNewTagName(importedTag.name)
setNewTagCategoryId('')
setCreating(true)
}
const handleMap = async () => {
if (!selectedTagId) return
setError(null)
setSaving(true)
try {
const res = await fetch('/api/tag-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
libraryId,
importedTagName: importedTag.name,
tagId: selectedTagId,
}),
})
if (!res.ok) {
const data = await res.json()
setError(data.error ?? 'Failed to save mapping')
setSaving(false)
return
}
setSaving(false)
setSelectedTagId('')
onMapped()
} catch {
setError('Network error')
setSaving(false)
}
}
const handleCreateAndMap = async () => {
if (!newTagName.trim() || !newTagCategoryId) return
setError(null)
setCreatingTag(true)
try {
const createRes = await fetch('/api/tags/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName.trim(), categoryId: newTagCategoryId }),
})
if (!createRes.ok) {
const data = await createRes.json()
setError(data.error ?? 'Failed to create tag')
setCreatingTag(false)
return
}
const newTag = await createRes.json()
const mapRes = await fetch('/api/tag-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
libraryId,
importedTagName: importedTag.name,
tagId: newTag.id,
}),
})
if (!mapRes.ok) {
const data = await mapRes.json()
setError(data.error ?? 'Failed to save mapping')
setCreatingTag(false)
return
}
setCreatingTag(false)
setCreating(false)
setNewTagName(importedTag.name)
setNewTagCategoryId('')
onMapped()
} catch {
setError('Network error')
setCreatingTag(false)
}
}
return (
<div className="py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
{/* Left: imported tag name + item count */}
<div className="flex-1 min-w-0">
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{importedTag.name}
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
({importedTag.itemCount})
</span>
</span>
</div>
{!creating ? (
<>
{/* Right: tag picker + map button + new button */}
<select
value={selectedTagId}
onChange={(e) => setSelectedTagId(e.target.value)}
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minWidth: 160,
}}
>
<option value="">Select tag</option>
{tagsByCategory.map((group) => (
<optgroup key={group.category.id} label={group.category.name}>
{group.tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</optgroup>
))}
</select>
<button
onClick={handleMap}
disabled={!selectedTagId || saving}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{saving ? 'Mapping…' : 'Map'}
</button>
<button
onClick={startCreating}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title="Create a new tag and map it"
>
+ New
</button>
<button
onClick={onIgnore}
className="text-xs px-2 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title="Hide this tag"
>
Ignore
</button>
</>
) : (
<>
{/* Inline create: category picker + name input + create & map button */}
<select
value={newTagCategoryId}
onChange={(e) => setNewTagCategoryId(e.target.value)}
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minWidth: 120,
}}
>
<option value="">Category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
placeholder="Tag name"
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 120,
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateAndMap()
if (e.key === 'Escape') setCreating(false)
}}
autoFocus
/>
<button
onClick={handleCreateAndMap}
disabled={!newTagName.trim() || !newTagCategoryId || creatingTag}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{creatingTag ? 'Creating…' : 'Create & Map'}
</button>
<button
onClick={() => setCreating(false)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
</>
)}
</div>
{error && (
<p className="text-xs mt-1.5 px-3 py-1 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
{error}
</p>
)}
</div>
)
})
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
function IgnoredTagsSection({
tags,
onUnignore,
}: {
tags: ImportedTag[]
onUnignore: (name: string) => void
}) {
const [expanded, setExpanded] = useState(false)
const parentRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: tags.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 8,
})
return (
<div className="mb-10">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 mb-3 group"
style={{ color: 'var(--text-secondary)' }}
>
<span className="text-xs transition-transform" style={{ display: 'inline-block', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
</span>
<span className="text-xs font-semibold uppercase tracking-wider">
Ignored Tags ({tags.length})
</span>
</button>
{expanded && (
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div ref={parentRef} className="px-5 py-4 max-h-[360px] overflow-auto">
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((row) => {
const t = tags[row.index]
return (
<div
key={t.name}
ref={rowVirtualizer.measureElement}
data-index={row.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${row.start}px)`,
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
}}
className="flex items-center gap-3 py-2"
>
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
{t.name}
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
</span>
<div className="flex-1" />
<button
onClick={() => onUnignore(t.name)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Unignore
</button>
</div>
)
})}
</div>
</div>
</div>
)}
</div>
)
}
// ─── Mapping Row ──────────────────────────────────────────────────────────────
function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) {
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleDeleteClick = () => {
if (!confirming) {
setConfirming(true)
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
return
}
if (cancelRef.current) clearTimeout(cancelRef.current)
setDeleting(true)
fetch(`/api/tag-mappings/${encodeURIComponent(mapping.id)}`, { method: 'DELETE' })
.then(() => onDeleted())
.catch(() => setDeleting(false))
}
return (
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{mapping.importedTagName}
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}></span>
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{mapping.categoryName}: {mapping.tagName}
</span>
<div className="flex-1" />
{confirming && (
<button
onClick={() => {
if (cancelRef.current) clearTimeout(cancelRef.current)
setConfirming(false)
}}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
)}
<button
onClick={handleDeleteClick}
disabled={deleting}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}
}}
onMouseLeave={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{deleting ? 'Deleting…' : confirming ? 'Confirm?' : 'Delete'}
</button>
</div>
)
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--text-secondary)' }}
>
{title}
</h2>
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
function LoadingRows() {
return (
<div className="flex flex-col gap-3">
{[70, 50, 85].map((w) => (
<div key={w} className="flex items-center gap-3">
<div
className="h-4 rounded animate-pulse"
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
/>
</div>
))}
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import type { Tag, TagCategory } from '@/types'
import type { Tag, TagCategory, Library, ImportedTag } from '@/types'
// ─── Main Page ────────────────────────────────────────────────────────────────
@@ -62,6 +62,8 @@ export default function ManageTagsPage() {
<Section title="Add a Category">
<AddCategoryForm onAdded={refresh} />
</Section>
<ImportedTagMappingsSection />
</div>
)
}
@@ -83,11 +85,13 @@ function CategoryBlock({
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [mergeConflict, setMergeConflict] = useState<{ name: string } | null>(null)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleRename = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setMergeConflict(null)
setSaving(true)
try {
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
@@ -96,8 +100,35 @@ function CategoryBlock({
body: JSON.stringify({ name: editName }),
})
const data = await res.json()
if (!res.ok) {
if (res.status === 409 && data.conflict) {
setMergeConflict({ name: editName.trim() })
setSaving(false)
return
}
setError(data.error); setSaving(false); return
}
setEditing(false)
onChanged()
} catch {
setError('Network error.')
}
setSaving(false)
}
const handleMerge = async () => {
setError(null)
setSaving(true)
try {
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName, merge: true }),
})
const data = await res.json()
if (!res.ok) { setError(data.error); setSaving(false); return }
setEditing(false)
setMergeConflict(null)
onChanged()
} catch {
setError('Network error.')
@@ -156,7 +187,7 @@ function CategoryBlock({
</button>
<button
type="button"
onClick={() => { setEditing(false); setEditName(category.name); setError(null) }}
onClick={() => { setEditing(false); setEditName(category.name); setError(null); setMergeConflict(null) }}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
@@ -228,6 +259,32 @@ function CategoryBlock({
</p>
)}
{mergeConflict && (
<div className="mb-3 px-3 py-2 rounded-lg text-xs" style={{ backgroundColor: '#78350f33', color: '#fbbf24' }}>
<p className="mb-2">
A category named &ldquo;{mergeConflict.name}&rdquo; already exists. This will merge all tags from
&ldquo;{category.name}&rdquo; into it. Tags with the same name will be combined.
</p>
<div className="flex gap-2">
<button
onClick={handleMerge}
disabled={saving}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: '#b45309', color: '#fff' }}
>
{saving ? 'Merging…' : 'Merge'}
</button>
<button
onClick={() => setMergeConflict(null)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
</div>
</div>
)}
{/* Tags list */}
<div className="flex flex-wrap gap-2 mb-3">
{tags.map((tag) => (
@@ -480,6 +537,117 @@ function AddCategoryForm({ onAdded }: { onAdded: () => void }) {
)
}
// ─── Imported Tag Mappings Section ────────────────────────────────────────────
function ImportedTagMappingsSection() {
const [libraries, setLibraries] = useState<Library[]>([])
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setError(null)
const libsRes = await fetch('/api/libraries')
const libsJson = await libsRes.json()
if (!Array.isArray(libsJson)) {
throw new Error('Failed to load libraries')
}
const comicLibs = libsJson.filter((l): l is Library => l?.type === 'comics')
if (cancelled) return
setLibraries(comicLibs)
setLoading(false)
if (comicLibs.length === 0) return
const settled = await Promise.allSettled(
comicLibs.map(async (lib) => {
const res = await fetch(`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`)
if (!res.ok) return { libraryId: lib.id, count: 0 }
const json = await res.json()
const count = Array.isArray(json) ? json.length : 0
return { libraryId: lib.id, count }
})
)
if (cancelled) return
const counts: Record<string, number> = {}
for (const result of settled) {
if (result.status === 'fulfilled') {
counts[result.value.libraryId] = result.value.count
}
}
setTagCounts(counts)
} catch {
if (cancelled) return
setError('Could not load imported tag mappings right now.')
setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [])
if (loading) {
return (
<Section title="Imported Tag Mappings">
<LoadingRows />
</Section>
)
}
if (libraries.length === 0) {
return (
<Section title="Imported Tag Mappings">
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No comic libraries configured. Add a comic library to import tags from ComicInfo.xml files.
</p>
</Section>
)
}
return (
<Section title="Imported Tag Mappings">
{error && (
<p className="text-xs mb-3 px-3 py-1.5 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
{error}
</p>
)}
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{libraries.map((lib) => (
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span className="flex-1 font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
{lib.name}
<span className="ml-2 font-normal text-xs" style={{ color: 'var(--text-secondary)' }}>
{tagCounts[lib.id] ?? 0} imported tag{(tagCounts[lib.id] ?? 0) === 1 ? '' : 's'}
</span>
</span>
<a
href={`/manage/tags/mappings/${encodeURIComponent(lib.id)}`}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors no-underline"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
Manage Mappings
</a>
</div>
))}
</div>
</Section>
)
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {

View File

@@ -0,0 +1,460 @@
'use client'
import { useEffect, useState, useRef } from 'react'
interface User {
id: string
username: string
role: 'admin' | 'user'
createdAt: number
}
interface Library {
id: string
name: string
type: string
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [libraries, setLibraries] = useState<Library[]>([])
const [loading, setLoading] = useState(true)
const refresh = () => {
Promise.all([
fetch('/api/users').then((r) => r.json()),
fetch('/api/libraries').then((r) => r.json()),
])
.then(([usersData, librariesData]) => {
setUsers(usersData)
setLibraries(librariesData)
setLoading(false)
})
.catch(() => setLoading(false))
}
useEffect(() => {
refresh()
}, [])
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Manage Users
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Create users and manage library access permissions.
</p>
<Section title="Users">
{loading ? (
<LoadingRows />
) : users.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No users found.
</p>
) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{users.map((user) => (
<UserRow key={user.id} user={user} libraries={libraries} onChanged={refresh} />
))}
</div>
)}
</Section>
<Section title="Add a User">
<AddUserForm onAdded={refresh} />
</Section>
</div>
)
}
// ─── Section ──────────────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--text-secondary)' }}
>
{title}
</h2>
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
// ─── User Row ─────────────────────────────────────────────────────────────────
function UserRow({
user,
libraries,
onChanged,
}: {
user: User
libraries: Library[]
onChanged: () => void
}) {
const [confirming, setConfirming] = useState(false)
const [removing, setRemoving] = useState(false)
const [showPermissions, setShowPermissions] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleDeleteClick = () => {
if (!confirming) {
setConfirming(true)
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
return
}
if (cancelRef.current) clearTimeout(cancelRef.current)
setRemoving(true)
fetch(`/api/users/${encodeURIComponent(user.id)}`, { method: 'DELETE' })
.then(async (r) => {
if (!r.ok) {
const data = await r.json()
alert(data.error ?? 'Failed to delete user')
setRemoving(false)
setConfirming(false)
} else {
onChanged()
}
})
.catch(() => {
setRemoving(false)
setConfirming(false)
})
}
const handleCancel = () => {
if (cancelRef.current) clearTimeout(cancelRef.current)
setConfirming(false)
}
return (
<div>
<div className="flex items-center justify-between gap-3 py-3">
<div className="flex items-center gap-3 min-w-0">
<span className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{user.username}
</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: user.role === 'admin' ? 'var(--accent)' : 'var(--surface-raised, var(--border))',
color: user.role === 'admin' ? 'var(--background)' : 'var(--text-secondary)',
}}
>
{user.role}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{user.role === 'user' && (
<button
onClick={() => setShowPermissions((v) => !v)}
className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
>
{showPermissions ? 'Hide' : 'Libraries'}
</button>
)}
{confirming ? (
<>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Sure?
</span>
<button
onClick={handleDeleteClick}
disabled={removing}
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
style={{ color: '#ef4444' }}
>
{removing ? 'Deleting…' : 'Yes, Delete'}
</button>
<button
onClick={handleCancel}
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
Cancel
</button>
</>
) : (
<button
onClick={handleDeleteClick}
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.color = '#ef4444'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
Delete
</button>
)}
</div>
</div>
{showPermissions && user.role === 'user' && (
<PermissionsPanel userId={user.id} libraries={libraries} />
)}
</div>
)
}
// ─── Permissions Panel ────────────────────────────────────────────────────────
type AccessLevel = 'none' | 'read' | 'write'
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
const [levels, setLevels] = useState<Record<string, AccessLevel>>({})
const [saving, setSaving] = useState(false)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
.then((r) => r.json())
.then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => {
const map: Record<string, AccessLevel> = {}
for (const p of data.permissions) {
map[p.libraryId] = p.accessLevel
}
setLevels(map)
setLoaded(true)
})
}, [userId])
const setLevel = (libraryId: string, level: AccessLevel) => {
setLevels((prev) => ({ ...prev, [libraryId]: level }))
}
const save = async () => {
setSaving(true)
const permissions = Object.entries(levels)
.filter(([, level]) => level !== 'none')
.map(([libraryId, accessLevel]) => ({ libraryId, accessLevel }))
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permissions }),
})
setSaving(false)
}
if (!loaded) {
return (
<div className="pb-3 pl-2">
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Loading</p>
</div>
)
}
return (
<div
className="mb-3 ml-2 p-3 rounded-lg border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--background)' }}
>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
Library Access
</p>
{libraries.length === 0 ? (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
) : (
<div className="space-y-2">
{libraries.map((lib) => {
const current = levels[lib.id] ?? 'none'
return (
<div key={lib.id} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
{lib.name}
</span>
<span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}>
({lib.type})
</span>
</div>
<div
className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium"
style={{ border: '1px solid var(--border)' }}
>
{(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => (
<button
key={lvl}
onClick={() => setLevel(lib.id, lvl)}
className="px-2.5 py-1 transition-colors capitalize"
style={{
backgroundColor: current === lvl ? 'var(--accent)' : 'transparent',
color: current === lvl ? 'var(--background)' : 'var(--text-secondary)',
}}
>
{lvl}
</button>
))}
</div>
</div>
)
})}
</div>
)}
<button
onClick={save}
disabled={saving}
className="mt-3 text-xs px-3 py-1.5 rounded-lg transition-opacity"
style={{
backgroundColor: 'var(--accent)',
color: 'var(--background)',
opacity: saving ? 0.6 : 1,
}}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
)
}
// ─── Add User Form ────────────────────────────────────────────────────────────
function AddUserForm({ onAdded }: { onAdded: () => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [role, setRole] = useState<'user' | 'admin'>('user')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role }),
})
if (!res.ok) {
const data = await res.json()
setError(data.error ?? 'Failed to create user')
return
}
setUsername('')
setPassword('')
setRole('user')
onAdded()
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--background)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
className="w-full px-3 py-2 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--background)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
Role
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="w-full px-3 py-2 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--background)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
<option value="user">User access only permitted libraries</option>
<option value="admin">Admin access all libraries and settings</option>
</select>
</div>
{error && (
<p className="text-sm" style={{ color: '#ef4444' }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="text-sm px-4 py-2 rounded-lg font-medium transition-opacity"
style={{
backgroundColor: 'var(--accent)',
color: 'var(--background)',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Creating…' : 'Create User'}
</button>
</form>
)
}
// ─── Loading skeleton ─────────────────────────────────────────────────────────
function LoadingRows() {
return (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{[1, 2, 3].map((i) => (
<div key={i} className="py-3 flex items-center gap-3">
<div
className="h-4 rounded animate-pulse"
style={{ width: '120px', backgroundColor: 'var(--border)' }}
/>
<div
className="h-4 rounded animate-pulse"
style={{ width: '50px', backgroundColor: 'var(--border)' }}
/>
</div>
))}
</div>
)
}

View File

@@ -1,14 +1,31 @@
import { redirect } from 'next/navigation'
import { getLibraries } from '@/lib/libraries'
import LibraryCard from '@/components/LibraryCard'
import Link from 'next/link'
export const dynamic = 'force-dynamic'
export default function HomePage() {
const libraries = getLibraries()
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { getLibrariesForUser } from '@/lib/users'
import LibraryCard from '@/components/LibraryCard'
export default async function HomePage() {
const session = await getServerSession()
if (!session.userId) redirect('/login')
const libraries = getLibrariesForUser(session.userId, session.role)
if (libraries.length === 0) {
if (session.role === 'admin') {
redirect('/manage')
}
return (
<div className="text-center py-16">
<p className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
No libraries available
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
An administrator needs to grant you access to libraries.
</p>
</div>
)
}
return (
<div>
@@ -18,16 +35,9 @@ export default function HomePage() {
Libraries
</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} available
</p>
</div>
<Link
href="/manage"
className="text-sm px-3 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}
>
Manage
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{libraries.map((lib) => (

View File

@@ -0,0 +1,136 @@
'use client'
import { useState } from 'react'
import type { UserSettings } from '@/types'
interface Props {
initialSettings: UserSettings
}
interface ToggleProps {
label: string
checked: boolean
onChange: (v: boolean) => void
}
function Toggle({ label, checked, onChange }: ToggleProps) {
return (
<label className="flex items-center justify-between py-2 cursor-pointer select-none">
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
{label}
</span>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="relative w-10 h-6 rounded-full transition-colors flex-shrink-0"
style={{
backgroundColor: checked ? 'var(--accent)' : 'var(--border)',
}}
>
<span
className="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
style={{ transform: checked ? 'translateX(16px)' : 'translateX(0)' }}
/>
</button>
</label>
)
}
interface SectionProps {
title: string
icon: string
children: React.ReactNode
}
function SettingsSection({ title, icon, children }: SectionProps) {
return (
<div
className="rounded-xl p-5 mb-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<h2 className="text-sm font-semibold uppercase tracking-wider mb-3 flex items-center gap-2"
style={{ color: 'var(--text-secondary)' }}>
<span>{icon}</span>
{title}
</h2>
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{children}
</div>
</div>
)
}
export default function SettingsForm({ initialSettings }: Props) {
const [settings, setSettings] = useState<UserSettings>(initialSettings)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
function set<K extends keyof UserSettings>(key: K, value: boolean) {
setSettings((prev) => ({ ...prev, [key]: value }))
setSaved(false)
}
async function handleSave() {
setSaving(true)
setError(null)
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
if (!res.ok) throw new Error('Failed to save')
setSaved(true)
} catch {
setError('Failed to save settings. Please try again.')
} finally {
setSaving(false)
}
}
return (
<div className="max-w-md">
<SettingsSection title="Mixed Library" icon="🗂️">
<Toggle label="Autoplay" checked={settings.mixedAutoplay} onChange={(v) => set('mixedAutoplay', v)} />
<Toggle label="Loop" checked={settings.mixedLoop} onChange={(v) => set('mixedLoop', v)} />
<Toggle label="Start muted" checked={settings.mixedMuted} onChange={(v) => set('mixedMuted', v)} />
</SettingsSection>
<SettingsSection title="Movies" icon="🎬">
<Toggle label="Autoplay" checked={settings.moviesAutoplay} onChange={(v) => set('moviesAutoplay', v)} />
<Toggle label="Loop" checked={settings.moviesLoop} onChange={(v) => set('moviesLoop', v)} />
<Toggle label="Start muted" checked={settings.moviesMuted} onChange={(v) => set('moviesMuted', v)} />
</SettingsSection>
<SettingsSection title="TV Shows" icon="📺">
<Toggle label="Autoplay" checked={settings.tvAutoplay} onChange={(v) => set('tvAutoplay', v)} />
<Toggle label="Loop" checked={settings.tvLoop} onChange={(v) => set('tvLoop', v)} />
<Toggle label="Start muted" checked={settings.tvMuted} onChange={(v) => set('tvMuted', v)} />
</SettingsSection>
<div className="flex items-center gap-3 mt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-opacity"
style={{ backgroundColor: 'var(--accent)', color: '#fff', opacity: saving ? 0.6 : 1 }}
>
{saving ? 'Saving…' : 'Save Settings'}
</button>
{saved && (
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Saved
</span>
)}
{error && (
<span className="text-sm" style={{ color: '#ef4444' }}>
{error}
</span>
)}
</div>
</div>
)
}

27
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { getUserSettings } from '@/lib/settings'
import SettingsForm from './SettingsForm'
export default async function SettingsPage() {
const session = await getServerSession()
if (!session.userId) redirect('/login')
const settings = getUserSettings(session.userId)
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Settings
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Signed in as <strong>{session.username}</strong>
</p>
<h2 className="text-base font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Video Playback
</h2>
<SettingsForm initialSettings={settings} />
</div>
)
}

View File

@@ -0,0 +1,820 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { useUserSettings } from '@/hooks/useUserSettings'
export interface DoomScrollItem {
url: string
name: string
mediaType: 'video' | 'image'
itemKey?: string
}
interface Props {
items: DoomScrollItem[]
videoContext?: 'mixed' | 'movies' | 'tv'
onClose: () => void
onViewInLibrary?: (item: DoomScrollItem) => void
}
const HISTORY_CAP = 100
function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem {
const excludeCount = Math.min(excludeRecent.length, items.length - 1)
const recentUrls = new Set(excludeRecent.slice(-excludeCount).map((i) => i.url))
const candidates = items.filter((i) => !recentUrls.has(i.url))
const pool = candidates.length > 0 ? candidates : items
return pool[Math.floor(Math.random() * pool.length)]
}
export default function DoomScrollView({ items, videoContext = 'mixed', onClose, onViewInLibrary }: Props) {
const settings = useUserSettings()
const settingsMuted = videoContext === 'mixed' ? settings.mixedMuted : videoContext === 'movies' ? settings.moviesMuted : settings.tvMuted
const [history, setHistory] = useState<DoomScrollItem[]>(() => {
if (items.length === 0) return []
return [pickRandom(items, [])]
})
const [historyIndex, setHistoryIndex] = useState(0)
const [localMuted, setLocalMuted] = useState(settingsMuted)
const [isPaused, setIsPaused] = useState(false)
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
// Tools overlay visibility
const [showToolsOverlay, setShowToolsOverlay] = useState(false)
// Rating state
const [userRating, setUserRatingState] = useState<number | null>(null)
const [ratingHover, setRatingHover] = useState<number | null>(null)
const [savingRating, setSavingRating] = useState(false)
// Text overlay state
const [extractedText, setExtractedText] = useState<string | null>(null)
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
const [savingText, setSavingText] = useState(false)
const [translatedText, setTranslatedText] = useState<string | null>(null)
const [showTextOverlay, setShowTextOverlay] = useState(false)
const [showOriginal, setShowOriginal] = useState(false)
const [extracting, setExtracting] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null)
const [extractPending, setExtractPending] = useState(false)
const [retranslating, setRetranslating] = useState(false)
const [translatePending, setTranslatePending] = useState(false)
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
const [sourceLanguage, setSourceLanguage] = useState('')
const videoRef = useRef<HTMLVideoElement>(null)
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cooldownRef = useRef(false)
const touchStartY = useRef<number | null>(null)
const current = history[historyIndex] ?? null
const isVideo = current?.mediaType === 'video'
const backCount = history.length - 1 - historyIndex
// Derived: what text to display in the overlay
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
const goNext = useCallback(() => {
if (items.length === 0) return
setHistoryIndex((idx) => {
if (idx < history.length - 1) {
return idx + 1
}
const next = pickRandom(items, history)
setHistory((h) => {
const updated = [...h, next]
return updated.length > HISTORY_CAP ? updated.slice(-HISTORY_CAP) : updated
})
return Math.min(idx + 1, HISTORY_CAP - 1)
})
}, [items, history])
const goPrev = useCallback(() => {
setHistoryIndex((idx) => Math.max(0, idx - 1))
}, [])
const navigate = useCallback((dir: 'next' | 'prev') => {
if (cooldownRef.current) return
cooldownRef.current = true
if (dir === 'next') goNext()
else goPrev()
setTimeout(() => { cooldownRef.current = false }, 300)
}, [goNext, goPrev])
// On navigation to a new item: reset pause state and start playing.
// Merging the reset + play() into one effect prevents the old isPaused=true
// value from calling pause() on the freshly-mounted video element before the
// reset fires. If autoplay is blocked by browser policy (common when unmuted),
// fall back to muted and retry — the user can unmute manually afterward.
useEffect(() => {
setIsPaused(false)
if (!videoRef.current) return
videoRef.current.play().catch(() => {
if (!videoRef.current) return
videoRef.current.muted = true
setLocalMuted(true)
videoRef.current.play().catch(() => {})
})
}, [current?.url])
// Sync muted imperatively — React's muted prop is not reliable
useEffect(() => {
if (videoRef.current) videoRef.current.muted = localMuted
}, [localMuted, current?.url])
// Sync play/pause imperatively for user-initiated pause/unpause only.
// current?.url is intentionally excluded: navigation is handled above.
useEffect(() => {
if (!videoRef.current) return
if (isPaused) {
videoRef.current.pause()
} else {
videoRef.current.play().catch(() => {})
}
}, [isPaused])
// Auto-play timer — resets on each new item, pause, enable/disable, or interval change
useEffect(() => {
if (!autoPlayEnabled || isPaused) return
const id = setTimeout(() => goNext(), autoPlaySeconds * 1000)
return () => clearTimeout(id)
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
// Fetch OCR settings once on mount
useEffect(() => {
fetch('/api/ai-settings/ocr')
.then((r) => r.json())
.then((d: { ocrMode: string; ocrLanguages: string }) => {
setDefaultOcrLanguages(d.ocrLanguages)
})
.catch(() => {})
}, [])
// Fetch extracted text + rating for current item; clear any in-flight poll on item change
useEffect(() => {
if (extractPollRef.current) {
clearInterval(extractPollRef.current)
extractPollRef.current = null
}
setExtractedText(null)
setEditedExtractedText('')
setTranslatedText(null)
setShowTextOverlay(false)
setShowOriginal(false)
setExtracting(false)
setExtractError(null)
setExtractPending(false)
setRetranslating(false)
setTranslatePending(false)
setUserRatingState(null)
setRatingHover(null)
if (!current?.itemKey) return
const key = current.itemKey
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`)
.then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
})
.catch(() => {})
fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`)
.then((r) => r.json())
.then((data: { userRating: number | null }) => {
setUserRatingState(data.userRating)
})
.catch(() => {})
}, [current?.itemKey])
// Clean up poll on unmount
useEffect(() => {
return () => {
if (extractPollRef.current) clearInterval(extractPollRef.current)
}
}, [])
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return }
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); navigate('next') }
if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); navigate('prev') }
if (e.key === 't' || e.key === 'T') {
if (extractedText) setShowTextOverlay((v) => !v)
}
}
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
navigate(e.deltaY > 0 ? 'next' : 'prev')
}
const handleTouchStart = (e: TouchEvent) => {
touchStartY.current = e.touches[0].clientY
}
const handleTouchEnd = (e: TouchEvent) => {
if (touchStartY.current === null) return
const delta = touchStartY.current - e.changedTouches[0].clientY
if (Math.abs(delta) > 50) navigate(delta > 0 ? 'next' : 'prev')
touchStartY.current = null
}
document.addEventListener('keydown', handleKey)
document.addEventListener('wheel', handleWheel, { passive: false })
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKey)
document.removeEventListener('wheel', handleWheel)
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
document.body.style.overflow = ''
}
}, [navigate, onClose, extractedText])
// ── Polling helper ──────────────────────────────────────────────────────────
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => {
if (!current?.itemKey) return
const itemKey = current.itemKey
if (extractPollRef.current) clearInterval(extractPollRef.current)
const deadline = Date.now() + 5 * 60 * 1000
extractPollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(extractPollRef.current!)
extractPollRef.current = null
setExtractPending(false)
setTranslatePending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
const textChanged = data.extractedText !== snapshotText
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
if (textChanged || translationChanged) {
clearInterval(extractPollRef.current!)
extractPollRef.current = null
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setExtractPending(false)
setTranslatePending(false)
if (data.extractedText) setShowTextOverlay(true)
}
} catch { /* ignore */ }
}, 2000)
}, [current?.itemKey])
// ── Rating actions ───────────────────────────────────────────────────────────
const handleSetRating = useCallback(async (star: number) => {
if (!current?.itemKey) return
const next = userRating === star ? null : star
setSavingRating(true)
try {
const res = await fetch('/api/ratings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: current.itemKey, userRating: next }),
})
if (res.ok) setUserRatingState(next)
} finally {
setSavingRating(false)
}
}, [current?.itemKey, userRating])
// ── Text extraction ──────────────────────────────────────────────────────────
const callExtract = useCallback(async (modeOverride: string) => {
if (!current?.itemKey) return
const itemKey = current.itemKey
setExtracting(true)
setExtractError(null)
setExtractPending(false)
try {
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey,
ocrMode: modeOverride,
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
}),
})
if (res.status === 202) {
setExtractPending(true)
startPolling(extractedText, translatedText)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
}
const result = await res.json()
const newText: string | null = result.extractedText || null
const newTranslated: string | null = result.translatedText || null
setExtractedText(newText)
setEditedExtractedText(newText ?? '')
setTranslatedText(newTranslated)
if (newText) setShowTextOverlay(true)
} catch (err) {
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
setTimeout(() => setExtractError(null), 4000)
} finally {
setExtracting(false)
}
}, [current?.itemKey, ocrLanguageInput, extractedText, translatedText, startPolling])
// ── Save edited extracted text ───────────────────────────────────────────────
const handleSaveExtractedText = useCallback(async () => {
if (!current?.itemKey) return
setSavingText(true)
try {
await fetch('/api/ai-tagging/fields', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: current.itemKey, extractedText: editedExtractedText }),
})
setExtractedText(editedExtractedText)
} finally {
setSavingText(false)
}
}, [current?.itemKey, editedExtractedText])
// ── Translation ──────────────────────────────────────────────────────────────
const handleTranslate = useCallback(async () => {
if (!current?.itemKey) return
setRetranslating(true)
setTranslatePending(false)
try {
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey: current.itemKey,
...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }),
}),
})
if (res.status === 202) {
setTranslatePending(true)
startPolling(extractedText, translatedText)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
const result = await res.json()
setTranslatedText(result.translatedText || null)
} catch {
// ignore
} finally {
setRetranslating(false)
}
}, [current?.itemKey, sourceLanguage, extractedText, translatedText, startPolling])
return (
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
{/* Keyframe for auto-play progress bar */}
<style>{`@keyframes doom-progress { from { width: 0% } to { width: 100% } }`}</style>
{/* Top bar */}
<div className="absolute top-0 left-0 right-0 flex items-center gap-2 p-3 z-10">
<span className="text-xs px-2 py-1 rounded flex-shrink-0" style={{ color: 'rgba(255,255,255,0.5)', backgroundColor: 'rgba(0,0,0,0.4)' }}>
{backCount > 0 ? `${backCount}` : 'Doom Scroll'}
</span>
{/* Auto-play controls */}
<div className="flex-1 flex items-center justify-center gap-2">
<button
onClick={() => setAutoPlayEnabled((v) => !v)}
className="px-3 py-1 rounded-full text-xs font-medium transition-colors flex-shrink-0"
style={{
backgroundColor: autoPlayEnabled ? 'var(--accent)' : 'rgba(0,0,0,0.5)',
color: '#fff',
}}
aria-label={autoPlayEnabled ? 'Disable auto-play' : 'Enable auto-play'}
>
Auto
</button>
{autoPlayEnabled && (
<div className="flex items-center gap-1">
<button
onClick={() => setAutoPlaySeconds((s) => Math.max(1, s - 1))}
className="w-6 h-6 rounded-full flex items-center justify-center text-sm flex-shrink-0"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
aria-label="Decrease interval"
>
</button>
<span className="text-xs text-center flex-shrink-0" style={{ color: 'rgba(255,255,255,0.8)', minWidth: '2.25rem' }}>
{autoPlaySeconds}s
</span>
<button
onClick={() => setAutoPlaySeconds((s) => Math.min(60, s + 1))}
className="w-6 h-6 rounded-full flex items-center justify-center text-sm flex-shrink-0"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
aria-label="Increase interval"
>
+
</button>
</div>
)}
</div>
<button
onClick={onClose}
className="w-9 h-9 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-opacity hover:opacity-100 opacity-80"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
aria-label="Close doom scroll"
>
</button>
</div>
{/* Media */}
<div className="flex-1 flex items-center justify-center overflow-hidden">
{isVideo && current ? (
<video
ref={videoRef}
key={current.url}
src={current.url}
autoPlay
loop={!autoPlayEnabled}
muted={localMuted}
playsInline
className="max-w-full max-h-full object-contain cursor-pointer"
style={{ backgroundColor: '#000' }}
onClick={() => setIsPaused((v) => !v)}
/>
) : current?.mediaType === 'image' ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={current.url}
src={current.url}
alt={current.name}
className="max-w-full max-h-full object-contain"
/>
) : null}
</div>
{/* Tools overlay — anchored lower-left, above the bottom bar */}
{showToolsOverlay && current?.itemKey && (
<div
className="absolute bottom-16 left-4 z-20 rounded-xl p-4 flex flex-col gap-3 overflow-y-auto"
style={{
backgroundColor: 'rgba(10,10,10,0.92)',
border: '1px solid rgba(255,255,255,0.12)',
width: 'min(320px, calc(100vw - 2rem))',
maxHeight: 'calc(100vh - 8rem)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* ── Rating ──────────────────────────────────────────── */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'rgba(255,255,255,0.45)' }}>
Rating
</p>
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
{[1, 2, 3, 4, 5].map((star) => {
const filled = (ratingHover ?? userRating ?? 0) >= star
return (
<button
key={star}
onClick={() => handleSetRating(star)}
onMouseEnter={() => setRatingHover(star)}
disabled={savingRating}
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
style={{
fontSize: '1.4rem',
color: filled ? '#f59e0b' : 'rgba(255,255,255,0.2)',
background: 'none',
border: 'none',
padding: '0 2px',
cursor: savingRating ? 'wait' : 'pointer',
transition: 'color 0.1s',
lineHeight: 1,
}}
>
</button>
)
})}
</div>
</div>
{/* ── Text Extraction (images only) ───────────────────── */}
{current.mediaType === 'image' && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '0.75rem' }}>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'rgba(255,255,255,0.45)' }}>
Text Extraction
</p>
<button
onClick={() => callExtract('llm')}
disabled={extracting || extractPending}
className="w-7 h-7 rounded-full flex items-center justify-center transition-opacity disabled:opacity-40"
style={{
backgroundColor: extractPending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
color: extractPending ? '#fff' : 'rgba(255,255,255,0.7)',
fontSize: '0.95rem',
}}
aria-label="Extract with AI"
title="Extract with AI (skips OCR)"
>
{extracting || extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-40 flex-shrink-0"
style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'rgba(255,255,255,0.7)' }}
>
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
</button>
<input
type="text"
value={ocrLanguageInput}
onChange={(e) => setOcrLanguageInput(e.target.value)}
placeholder={defaultOcrLanguages}
className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{
backgroundColor: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.85)',
width: 120,
}}
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
/>
</div>
{extractError && (
<p className="text-xs mt-1" style={{ color: '#f87171' }}>{extractError}</p>
)}
{/* Extracted text editor */}
{extractedText !== null && (
<div className="flex flex-col gap-1 mt-2">
<p className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.45)' }}>Extracted Text</p>
<textarea
value={editedExtractedText}
onChange={(e) => setEditedExtractedText(e.target.value)}
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
style={{
backgroundColor: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.9)',
minHeight: '3.5rem',
maxHeight: '8rem',
fontFamily: 'inherit',
}}
/>
{editedExtractedText !== extractedText && (
<button
onClick={handleSaveExtractedText}
disabled={savingText}
className="self-start text-xs px-2 py-0.5 rounded-full transition-opacity disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingText ? '⟳ Saving…' : 'Save'}
</button>
)}
{/* Translation display */}
{translatedText && (
<div className="mt-1">
<p className="text-xs font-medium mb-1" style={{ color: 'rgba(255,255,255,0.45)' }}>Translation</p>
<pre
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-32 overflow-y-auto"
style={{
backgroundColor: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.9)',
}}
>
{translatedText}
</pre>
</div>
)}
{/* Original / translation toggle */}
{extractedText && translatedText && (
<button
onClick={() => setShowOriginal((v) => !v)}
className="self-start text-xs px-2 py-0.5 rounded-full mt-1"
style={{ backgroundColor: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation in popover' : 'Show Original in popover'}
</button>
)}
{/* Translate / re-translate */}
<div className="flex items-center gap-1.5 flex-wrap mt-1">
<input
type="text"
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
placeholder="Source lang…"
className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{
backgroundColor: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.85)',
width: 100,
}}
/>
<button
onClick={handleTranslate}
disabled={retranslating || translatePending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-40"
style={{
backgroundColor: translatePending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
color: translatePending ? '#fff' : 'rgba(255,255,255,0.7)',
}}
>
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p>
</div>
)}
{/* Bottom bar: [mute + tools] | filename | action buttons */}
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
<div className="flex items-center gap-1 flex-shrink-0">
{isVideo && (
<button
onClick={() => setLocalMuted((v) => !v)}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
aria-label={localMuted ? 'Unmute' : 'Mute'}
>
{localMuted ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<line x1="23" y1="9" x2="17" y2="15"/>
<line x1="17" y1="9" x2="23" y2="15"/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
)}
</button>
)}
{current?.itemKey && (
<button
onClick={() => setShowToolsOverlay((v) => !v)}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
style={{
backgroundColor: showToolsOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
color: '#fff',
}}
aria-label={showToolsOverlay ? 'Close tools' : 'Open tools'}
title="Rating &amp; text tools"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
</svg>
</button>
)}
</div>
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
{current?.name}
</span>
<div className="flex-shrink-0 flex items-center gap-1">
{extractedText ? (
<button
onClick={() => setShowTextOverlay((v) => !v)}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
style={{
backgroundColor: showTextOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
color: '#fff',
}}
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="15" y2="12"/>
<line x1="3" y1="18" x2="18" y2="18"/>
</svg>
</button>
) : current?.itemKey && current?.mediaType === 'image' ? (
<button
onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
style={{
backgroundColor: extractPending
? 'var(--accent)'
: extractError
? 'rgba(127,29,29,0.8)'
: 'rgba(0,0,0,0.5)',
color: extractError ? '#fca5a5' : '#fff',
}}
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
>
{extracting || extractPending ? (
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}></span>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
)}
</button>
) : null}
{onViewInLibrary && current?.itemKey && (
<button
onClick={(e) => { e.stopPropagation(); onViewInLibrary(current) }}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
aria-label="View in library"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
)}
</div>
</div>
{/* Auto-play progress bar — key on current URL restarts animation on each new item */}
{autoPlayEnabled && !isPaused && (
<div
key={current?.url}
className="absolute bottom-0 left-0 h-0.5 z-20"
style={{
backgroundColor: 'var(--accent)',
animationName: 'doom-progress',
animationDuration: `${autoPlaySeconds}s`,
animationTimingFunction: 'linear',
animationFillMode: 'forwards',
}}
/>
)}
{/* Prev / Next hint arrows */}
{historyIndex > 0 && (
<button
onClick={() => navigate('prev')}
className="absolute left-1/2 top-16 -translate-x-1/2 w-10 h-10 rounded-full flex items-center justify-center text-xl transition-opacity hover:opacity-100 opacity-50 z-10"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
<button
onClick={() => navigate('next')}
className="absolute left-1/2 bottom-14 -translate-x-1/2 w-10 h-10 rounded-full flex items-center justify-center text-xl transition-opacity hover:opacity-100 opacity-50 z-10"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
</div>
)
}

View File

@@ -0,0 +1,198 @@
'use client'
import { useEffect, useState } from 'react'
import type { Tag, TagCategory, RatingOperator } from '@/types'
interface Props {
libraryId: string
assignments: Record<string, string[]>
search: string
onSearchChange: (q: string) => void
selectedTagIds: Set<string>
onTagToggle: (tagId: string) => void
refreshKey?: number
ratingValue: number | null
ratingOperator: RatingOperator
onRatingChange: (value: number | null, operator: RatingOperator) => void
showRatingFilter?: boolean
}
export default function FilterPanel({
assignments,
search,
onSearchChange,
selectedTagIds,
onTagToggle,
refreshKey,
ratingValue,
ratingOperator,
onRatingChange,
showRatingFilter = true,
}: Props) {
const [categories, setCategories] = useState<TagCategory[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch('/api/tags/categories').then((r) => r.json()),
fetch('/api/tags/items').then((r) => r.json()),
])
.then(([cats, ts]: [TagCategory[], Tag[]]) => {
setCategories(cats)
setTags(ts)
})
.finally(() => setLoading(false))
}, [refreshKey])
const usedTagIds = new Set(Object.values(assignments).flat())
const usedTags = tags.filter((t) => usedTagIds.has(t.id))
const usedCategoryIds = new Set(usedTags.map((t) => t.categoryId))
const usedCategories = categories.filter((c) => usedCategoryIds.has(c.id))
const hasTags = usedTags.length > 0
return (
<div className="flex flex-col gap-4">
{/* Search */}
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-lg px-3 py-2 text-sm outline-none"
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
/>
{/* Rating filter */}
{showRatingFilter && (
<div className="flex flex-col gap-1.5">
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Rating</p>
{/* Operator toggle */}
<div className="flex gap-1">
{(['gte', 'eq', 'lte'] as RatingOperator[]).map((op) => {
const label = op === 'gte' ? '≥' : op === 'eq' ? '=' : '≤'
const active = ratingValue !== null && ratingOperator === op
return (
<button
key={op}
onClick={() => onRatingChange(active ? null : (ratingValue ?? 3), op)}
className="flex-1 py-0.5 rounded text-xs font-medium transition-colors"
style={{
backgroundColor: active ? 'var(--accent)' : 'var(--border)',
color: active ? '#fff' : 'var(--text-secondary)',
cursor: 'pointer',
}}
>
{label}
</button>
)
})}
</div>
{/* Star picker */}
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => {
const lit =
ratingValue !== null &&
((ratingOperator === 'gte' && star <= ratingValue) ||
(ratingOperator === 'eq' && star === ratingValue) ||
(ratingOperator === 'lte' && star >= ratingValue))
return (
<button
key={star}
onClick={() => onRatingChange(ratingValue === star ? null : star, ratingOperator)}
className="flex-1 text-base py-0.5 rounded transition-colors"
style={{
color: lit ? '#f59e0b' : 'var(--border)',
background: 'none',
cursor: 'pointer',
}}
aria-label={`${star} star${star !== 1 ? 's' : ''}`}
>
</button>
)
})}
</div>
</div>
)}
{/* Tag filters */}
{loading ? (
<div className="flex flex-col gap-3">
{[0, 1].map((i) => (
<div key={i} className="flex flex-col gap-1.5">
<div
className="h-3 w-16 rounded animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
/>
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
{[50, 65, 42].map((w) => (
<div
key={w}
className="h-5 rounded-full animate-pulse"
style={{ width: w, backgroundColor: 'var(--border)' }}
/>
))}
</div>
</div>
))}
</div>
) : hasTags ? (
<div className="flex flex-col gap-3">
{usedCategories.map((cat) => {
const catTags = usedTags.filter((t) => t.categoryId === cat.id)
if (catTags.length === 0) return null
return (
<div key={cat.id}>
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
{cat.name}
</p>
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
{catTags.map((tag) => {
const active = selectedTagIds.has(tag.id)
return (
<button
key={tag.id}
onClick={() => onTagToggle(tag.id)}
className="px-2 py-0.5 rounded-full text-xs font-medium transition-colors"
style={{
backgroundColor: active ? 'var(--accent)' : 'var(--border)',
color: active ? '#fff' : 'var(--text-secondary)',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = active ? 'var(--accent-hover)' : 'var(--text-secondary)'
if (!active) el.style.color = 'var(--background)'
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = active ? 'var(--accent)' : 'var(--border)'
el.style.color = active ? '#fff' : 'var(--text-secondary)'
}}
>
{tag.name}
</button>
)
})}
</div>
</div>
)
})}
</div>
) : (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
No tags defined.{' '}
<a href="/manage/tags" style={{ color: 'var(--accent)' }}>
Manage tags
</a>
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import NavLink from './NavLink'
interface Props {
username: string
isAdmin: boolean
}
export default function HeaderNav({ username, isAdmin }: Props) {
const router = useRouter()
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/login')
router.refresh()
}
return (
<div className="flex items-center gap-3">
{isAdmin && <NavLink href="/manage">Manage</NavLink>}
<Link
href="/settings"
className="text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
{username}
</Link>
<button
onClick={handleLogout}
className="text-sm px-3 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
Sign Out
</button>
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
import type { Library } from '@/types'
const TYPE_LABELS: Record<string, string> = {
@@ -17,7 +18,7 @@ export default function LibraryCard({ library }: { library: Library }) {
return (
<Link
href={`/library/${library.id}`}
className="group block rounded-xl border p-5 transition-colors"
className="group block rounded-xl border overflow-hidden transition-colors"
style={{
borderColor: 'var(--border)',
backgroundColor: 'var(--surface)',
@@ -31,7 +32,25 @@ export default function LibraryCard({ library }: { library: Library }) {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
}}
>
<div className="text-3xl mb-3">{TYPE_ICONS[library.type] ?? '📁'}</div>
{library.coverExt ? (
<div className="relative w-full aspect-video">
<Image
src={`/api/library-cover/${library.id}`}
alt={library.name}
fill
className="object-cover"
unoptimized
/>
</div>
) : (
<div
className="w-full aspect-video flex items-center justify-center text-4xl"
style={{ backgroundColor: 'var(--border)' }}
>
{TYPE_ICONS[library.type] ?? '📁'}
</div>
)}
<div className="p-4">
<div className="font-semibold text-base mb-1" style={{ color: 'var(--text-primary)' }}>
{library.name}
</div>
@@ -41,6 +60,7 @@ export default function LibraryCard({ library }: { library: Library }) {
>
{TYPE_LABELS[library.type] ?? library.type}
</div>
</div>
</Link>
)
}

View File

@@ -6,6 +6,9 @@ import { usePathname } from 'next/navigation'
const TABS = [
{ href: '/manage', label: 'Libraries' },
{ href: '/manage/tags', label: 'Tags' },
{ href: '/manage/users', label: 'Users' },
{ href: '/manage/scanning', label: 'Scanning' },
{ href: '/manage/ai-tagging', label: 'AI Integrations' },
]
export default function ManageSubNav() {

View File

@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
interface Props {
libraryId: string
}
export default function ScanLibraryButton({ libraryId }: Props) {
const [scanning, setScanning] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const handleScan = async () => {
setScanning(true)
setMessage(null)
try {
const res = await fetch(`/api/scan/${encodeURIComponent(libraryId)}`, { method: 'POST' })
if (res.status === 409) {
setMessage('A scan is already in progress.')
}
} catch {
setMessage('Failed to start scan.')
} finally {
setScanning(false)
}
}
return (
<div className="flex items-center gap-3">
<button
onClick={handleScan}
disabled={scanning}
className="text-sm px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!scanning) (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{scanning ? 'Scanning…' : 'Scan'}
</button>
{message && (
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{message}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,332 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { ComicIssue } from '@/types'
import ImageLightbox from '@/components/mixed/ImageLightbox'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
interface Props {
libraryId: string
issue: ComicIssue
onClose: () => void
onPrev?: () => void
onNext?: () => void
onTagsChanged?: () => void
onDeleted?: () => void
readOnly?: boolean
}
function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string {
return `/api/comics/page?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}&pageIndex=${pageIndex}`
}
export default function ComicIssueView({ libraryId, issue, onClose, onPrev, onNext, onTagsChanged, onDeleted, readOnly }: Props) {
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const menuRef = useRef<HTMLDivElement>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (lightboxPage !== null) return
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, onPrev, onNext, lightboxPage, showTagPanel, menuOpen, confirming])
// Close menu on outside click
useEffect(() => {
if (!menuOpen) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [menuOpen])
const handleDelete = async () => {
setDeleting(true)
try {
await fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}`, { method: 'DELETE' })
onDeleted?.()
} catch {
setDeleting(false)
setConfirming(false)
}
}
const pageCount = issue.pageCount
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
const gridRef = useRef<HTMLDivElement>(null)
return (
<>
<div
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={onClose}
>
{/* Floating prev/next arrows */}
{onPrev && !showTagPanel && (
<button
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
onClick={(e) => { e.stopPropagation(); onPrev() }}
aria-label="Previous issue"
>
</button>
)}
{onNext && !showTagPanel && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
onClick={(e) => { e.stopPropagation(); onNext() }}
aria-label="Next issue"
>
</button>
)}
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
<div
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
>
<div
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
maxHeight: '90vh',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="min-w-0">
<p className="font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{issue.title}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{pageCount} {pageCount === 1 ? 'page' : 'pages'}
</p>
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
{issue.item_key && !readOnly && !showTagPanel && (
<button
onClick={(e) => { e.stopPropagation(); setShowTagPanel(true) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
title="Tags"
aria-label="Show tags"
>
🏷
</button>
)}
{/* Kebab menu */}
<div className="relative" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-base font-bold transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
aria-label="More options"
title="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-10 min-w-[120px]"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<a
href={downloadUrl}
download
className="flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
style={{ color: 'var(--text-primary)' }}
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
>
Download
</a>
{!readOnly && (
<button
className="w-full text-left flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
style={{ color: '#fca5a5' }}
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
>
Delete
</button>
)}
</div>
)}
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
</div>
{/* Delete confirmation */}
{confirming && (
<div
className="flex items-center gap-3 mx-5 mt-3 px-3 py-2.5 rounded-lg text-sm flex-shrink-0"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this issue and its file?
</p>
<button
onClick={() => setConfirming(false)}
className="px-2 py-1 rounded text-xs transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-2 py-1 rounded text-xs font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Cover + tags */}
<div
className="flex gap-5 px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div
className="flex-shrink-0 rounded-lg overflow-hidden"
style={{ width: 140, aspectRatio: '2/3', background: 'var(--border)' }}
>
{issue.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={issue.coverUrl}
alt={issue.title}
className="w-full h-full object-cover"
/>
) : pageCount > 0 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={pageUrl(libraryId, issueKey, 0)}
alt={issue.title}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-3xl"
style={{ color: 'var(--text-secondary)' }}
>
📖
</div>
)}
</div>
<div className="flex-1 min-w-0 pt-1">
<p className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{issue.item_key ? (
<AssignedTagBadges itemKey={issueKey} refreshKey={tagRefreshKey} />
) : (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No tags</p>
)}
</div>
</div>
{/* Page grid */}
<div className="overflow-y-auto flex-1 p-4" ref={gridRef}>
{pageCount === 0 ? (
<div
className="flex items-center justify-center py-16 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
No pages found in this issue.
</div>
) : (
<div className="grid gap-2 grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{Array.from({ length: pageCount }, (_, i) => (
<button
key={i}
className="relative rounded overflow-hidden focus:outline-none focus:ring-2 focus:ring-offset-1 group"
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
onClick={() => setLightboxPage(i)}
aria-label={`Page ${i + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={pageUrl(libraryId, issueKey, i)}
alt={`Page ${i + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
<div
className="absolute bottom-0 inset-x-0 py-0.5 text-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
>
{i + 1}
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
{showTagPanel && issue.item_key && (
<MediaTagPanel
itemKey={issueKey}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</div>
</div>
{lightboxPage !== null && (
<ImageLightbox
url={pageUrl(libraryId, issueKey, lightboxPage)}
name={`Page ${lightboxPage + 1} of ${pageCount}`}
onClose={() => setLightboxPage(null)}
onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined}
onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined}
itemKey={issueKey}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</>
)
}

View File

@@ -0,0 +1,233 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { ComicIssue, ComicSeries } from '@/types'
import ComicIssueView from './ComicIssueView'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
interface Props {
libraryId: string
series: ComicSeries
onClose: () => void
onTagsChanged?: () => void
readOnly?: boolean
}
export default function ComicSeriesView({ libraryId, series, onClose, onTagsChanged, readOnly }: Props) {
const [issues, setIssues] = useState<ComicIssue[]>([])
const [loading, setLoading] = useState(true)
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
const [tagItemKey, setTagItemKey] = useState<string | null>(null)
const fetchIssues = useCallback(() => {
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(series.id)}`)
.then((r) => r.json())
.then((data: ComicIssue[]) => {
setIssues(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, [libraryId, series.id])
useEffect(() => { fetchIssues() }, [fetchIssues])
// Escape closes tag panel first, then series view
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && !selectedIssue && !tagItemKey) onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, selectedIssue, tagItemKey])
return (
<>
<div
className="fixed inset-0 z-40 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={onClose}
>
<div className={`flex h-full w-full ${tagItemKey ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
<div className={tagItemKey ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-3xl'}>
<div
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
maxHeight: '90vh',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="min-w-0">
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{series.title}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
</p>
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
{series.item_key && !readOnly && !tagItemKey && (
<button
onClick={(e) => { e.stopPropagation(); setTagItemKey(series.item_key!) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
title="Tag series"
aria-label="Tag series"
>
🏷
</button>
)}
<button
onClick={onClose}
className="w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
</div>
{/* Issue grid */}
<div className="overflow-y-auto flex-1 p-4">
{loading ? (
<LoadingGrid />
) : issues.length === 0 ? (
<div
className="flex items-center justify-center py-16 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
No issues found.
</div>
) : (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{issues.map((issue) => (
<IssueCard
key={issue.id}
issue={issue}
readOnly={readOnly}
onClick={() => setSelectedIssue(issue)}
onTagClick={issue.item_key && !readOnly
? () => setTagItemKey(issue.item_key!)
: undefined}
/>
))}
</div>
)}
</div>
</div>
</div>
{tagItemKey && (
<MediaTagPanel
itemKey={tagItemKey}
onHide={() => setTagItemKey(null)}
onClose={onClose}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
</div>
</div>
{selectedIssue && (
<ComicIssueView
libraryId={libraryId}
issue={selectedIssue}
onClose={() => setSelectedIssue(null)}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
</>
)
}
function IssueCard({
issue,
onClick,
onTagClick,
readOnly,
}: {
issue: ComicIssue
onClick: () => void
onTagClick?: () => void
readOnly?: boolean
}) {
return (
<div
className="relative rounded-xl overflow-hidden group"
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
>
<button
className="text-left w-full focus:outline-none focus:ring-2"
onClick={onClick}
>
<div
className="relative w-full overflow-hidden"
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
>
{issue.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={issue.coverUrl}
alt={issue.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-3xl">📖</div>
)}
{issue.issueNumber !== null && (
<div
className="absolute top-1 left-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
>
#{issue.issueNumber}
</div>
)}
</div>
<div className="px-2 pt-1.5 pb-1">
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
{issue.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
</p>
</div>
</button>
{onTagClick && !readOnly && (
<button
onClick={(e) => { e.stopPropagation(); onTagClick() }}
className="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
title="Tag issue"
aria-label="Tag issue"
>
🏷
</button>
)}
</div>
)
}
function LoadingGrid() {
return (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
<div className="p-2 space-y-1">
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '80%' }} />
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,561 @@
'use client'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
import type { ComicIssue, ComicSeries, RatingOperator } from '@/types'
import { useDebounce } from '@/hooks/useDebounce'
import ComicIssueView from './ComicIssueView'
import FilterPanel from '@/components/FilterPanel'
import TagSelector from '@/components/tags/TagSelector'
interface Props {
libraryId: string
readOnly?: boolean
}
const PAGE_SIZE = 200
export default function ComicsView({ libraryId, readOnly }: Props) {
const [items, setItems] = useState<(ComicIssue | ComicSeries)[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
const [seriesIssues, setSeriesIssues] = useState<ComicIssue[]>([])
const [seriesIssuesLoading, setSeriesIssuesLoading] = useState(false)
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
const [selectedIssueIndex, setSelectedIssueIndex] = useState<number | null>(null)
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [ratingValue, setRatingValue] = useState<number | null>(null)
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
const debouncedSearch = useDebounce(search, 200)
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
Record<string, { tagIds: string[]; issueTitles: string[] }>
>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sentinelRef = useRef<HTMLDivElement | null>(null)
const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => {
const next = new Set(prev)
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
return next
})
const fetchItems = useCallback((pageNum: number, searchVal: string, replace: boolean) => {
const params = new URLSearchParams({
libraryId,
page: String(pageNum),
pageSize: String(PAGE_SIZE),
})
if (searchVal) params.set('search', searchVal)
if (pageNum === 1) setLoading(true)
else setLoadingMore(true)
fetch(`/api/comics?${params}`)
.then((r) => r.json())
.then((data: { items: (ComicIssue | ComicSeries)[]; total: number }) => {
setItems((prev) => (replace ? data.items : [...prev, ...data.items]))
setTotal(data.total)
if (pageNum === 1) setLoading(false)
else setLoadingMore(false)
})
.catch(() => {
setError('Failed to load comics')
setLoading(false)
})
}, [libraryId])
useEffect(() => { fetchItems(1, '', true) }, [fetchItems])
// Fetch issues when a series is selected
useEffect(() => {
if (!selectedSeries) { setSeriesIssues([]); return }
setSeriesIssuesLoading(true)
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
.then((r) => r.json())
.then((data: ComicIssue[]) => { setSeriesIssues(data); setSeriesIssuesLoading(false) })
.catch(() => setSeriesIssuesLoading(false))
}, [selectedSeries, libraryId])
// IntersectionObserver: load next page when sentinel scrolls into view
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel || items.length >= total || total === 0) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !loadingMore) {
const next = page + 1
setPage(next)
fetchItems(next, search, false)
}
},
{ rootMargin: '400px' }
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [items.length, total, loadingMore, page, search, fetchItems])
const handleSearchChange = useCallback((val: string) => {
setSearch(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
setPage(1)
fetchItems(1, val, true)
}, 300)
}, [fetchItems])
const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setAssignments)
.catch(() => {})
}, [libraryId])
useEffect(() => { fetchAssignments() }, [fetchAssignments])
const fetchSeriesIssueMeta = useCallback(() => {
fetch(`/api/comics/series-issue-tags?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setSeriesIssueMeta)
.catch(() => {})
}, [libraryId])
useEffect(() => { fetchSeriesIssueMeta() }, [fetchSeriesIssueMeta])
const onTagsChanged = useCallback(() => {
setFilterRefreshKey((k) => k + 1)
fetchAssignments()
fetchSeriesIssueMeta()
}, [fetchAssignments, fetchSeriesIssueMeta])
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
if (value === ratingValue && operator === ratingOperator) {
setRatingValue(null)
} else {
setRatingValue(value)
setRatingOperator(operator)
}
}
const filtered = useMemo(() => items.filter((item) => {
const isSeries = 'issueCount' in item
const series = isSeries ? (item as ComicSeries) : null
const issue = isSeries ? null : (item as ComicIssue)
if (series) {
const meta = seriesIssueMeta[series.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
const titleMatch = series.title.toLowerCase().includes(q)
const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q))
const aiMatch = series.aiDescription?.toLowerCase().includes(q) ?? false
const textMatch = series.extractedText?.toLowerCase().includes(q) ?? false
const translatedMatch = series.extractedTextTranslated?.toLowerCase().includes(q) ?? false
if (!titleMatch && !issueMatch && !aiMatch && !textMatch && !translatedMatch) return false
}
if (selectedTagIds.size > 0) {
const seriesTags = assignments[series.item_key ?? ''] ?? []
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
}
if (ratingValue !== null) {
const r = series.userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
}
// Standalone issue
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
if (![issue!.title, issue!.aiDescription, issue!.extractedText, issue!.extractedTextTranslated]
.some((f) => f?.toLowerCase().includes(q))) return false
}
if (selectedTagIds.size > 0) {
const tags = assignments[issue!.item_key ?? ''] ?? []
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
}
if (ratingValue !== null) {
const r = issue!.userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
}), [items, debouncedSearch, selectedTagIds, assignments, seriesIssueMeta, ratingValue, ratingOperator])
// Flat list of issues at the current navigation level for prev/next
const filteredIssues: ComicIssue[] = selectedSeries
? seriesIssues
: filtered.filter((item): item is ComicIssue => !('issueCount' in item))
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
return (
<>
<div className="flex items-center gap-2 mb-4">
<button
onClick={() => setShowFilters((v) => !v)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
>
Filters{filtersActive ? ' ●' : ''}
</button>
</div>
<div className="flex flex-col md:flex-row gap-6 md:items-start">
{showFilters && (
<div className="w-full md:w-52 md:flex-shrink-0">
<FilterPanel
libraryId={libraryId}
assignments={assignments}
search={search}
onSearchChange={handleSearchChange}
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
ratingValue={ratingValue}
ratingOperator={ratingOperator}
onRatingChange={handleRatingChange}
/>
</div>
)}
<div className="flex-1 min-w-0">
{/* Breadcrumb when inside a series */}
{selectedSeries && (
<div className="flex items-center gap-2 mb-4 text-sm">
<button
onClick={() => { setSelectedSeries(null); setSeriesIssues([]); setSearch('') }}
className="transition-colors"
style={{ color: 'var(--accent)' }}
>
All Comics
</button>
<span style={{ color: 'var(--text-secondary)' }}>/</span>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedSeries.title}
</span>
</div>
)}
{loading ? (
<LoadingGrid />
) : error ? (
<div
className="rounded-lg border p-8 text-center"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
{error}
</div>
) : items.length === 0 ? (
<div
className="rounded-lg border p-12 text-center"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
<p className="text-lg mb-1">No comics found</p>
<p className="text-sm">Add .cbz files or folders of .cbz files to this library and scan.</p>
</div>
) : (
<>
{!selectedSeries && total > PAGE_SIZE && (
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
</p>
)}
{seriesIssuesLoading ? (
<LoadingGrid />
) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{selectedSeries
? seriesIssues.map((issue) => (
<IssueCard
key={issue.id}
issue={issue}
readOnly={readOnly}
onClick={() => { setSelectedIssue(issue); setSelectedIssueIndex(seriesIssues.indexOf(issue)) }}
onTagClick={issue.item_key && !readOnly
? () => setTagPanel({ itemKey: issue.item_key!, title: issue.title })
: undefined}
/>
))
: filtered.map((item) =>
'issueCount' in item ? (
<SeriesCard
key={item.id}
series={item as ComicSeries}
readOnly={readOnly}
onClick={() => { setSelectedSeries(item as ComicSeries); setSearch('') }}
onTagClick={(item as ComicSeries).item_key && !readOnly
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
: undefined}
/>
) : (
<IssueCard
key={item.id}
issue={item as ComicIssue}
readOnly={readOnly}
onClick={() => {
const issue = item as ComicIssue
setSelectedIssue(issue)
setSelectedIssueIndex(filteredIssues.indexOf(issue))
}}
onTagClick={(item as ComicIssue).item_key && !readOnly
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
: undefined}
/>
)
)
}
</div>
)}
{!selectedSeries && (
<>
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
{loadingMore && <LoadingMore />}
</>
)}
</>
)}
</div>
</div>
{/* Tag panel modal */}
{tagPanel && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
>
<div
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{tagPanel.title}
</p>
</div>
<button
onClick={() => setTagPanel(null)}
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
<div className="px-5 py-4">
<TagSelector
itemKey={tagPanel.itemKey}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
</div>
</div>
</div>
)}
{selectedIssue && (
<ComicIssueView
libraryId={libraryId}
issue={selectedIssue}
onClose={() => { setSelectedIssue(null); setSelectedIssueIndex(null) }}
onPrev={selectedIssueIndex !== null && selectedIssueIndex > 0
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex - 1]); setSelectedIssueIndex(selectedIssueIndex - 1) }
: undefined}
onNext={selectedIssueIndex !== null && selectedIssueIndex < filteredIssues.length - 1
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex + 1]); setSelectedIssueIndex(selectedIssueIndex + 1) }
: undefined}
onTagsChanged={onTagsChanged}
onDeleted={() => {
setSelectedIssue(null)
setSelectedIssueIndex(null)
fetchItems(1, search, true)
fetchAssignments()
if (selectedSeries) {
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
.then((r) => r.json())
.then((data: ComicIssue[]) => setSeriesIssues(data))
.catch(() => {})
}
}}
readOnly={readOnly}
/>
)}
</>
)
}
function SeriesCard({
series,
onClick,
onTagClick,
readOnly,
}: {
series: ComicSeries
onClick: () => void
onTagClick?: () => void
readOnly?: boolean
}) {
return (
<div
className="relative rounded-xl overflow-hidden group"
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
>
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
<div
className="relative w-full overflow-hidden"
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
>
{series.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={series.coverUrl}
alt={series.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-4xl">📚</div>
)}
<div
className="absolute top-1 right-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
>
{series.issueCount}
</div>
</div>
<div className="px-2 pt-1.5 pb-1">
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
{series.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
</p>
</div>
</button>
{onTagClick && !readOnly && (
<button
onClick={(e) => { e.stopPropagation(); onTagClick() }}
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
title="Tag series"
aria-label="Tag series"
>
🏷
</button>
)}
</div>
)
}
function IssueCard({
issue,
onClick,
onTagClick,
readOnly,
}: {
issue: ComicIssue
onClick: () => void
onTagClick?: () => void
readOnly?: boolean
}) {
return (
<div
className="relative rounded-xl overflow-hidden group"
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
>
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
<div
className="relative w-full overflow-hidden"
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
>
{issue.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={issue.coverUrl}
alt={issue.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-4xl">📖</div>
)}
</div>
<div className="px-2 pt-1.5 pb-1">
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
{issue.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
</p>
</div>
</button>
{onTagClick && !readOnly && (
<button
onClick={(e) => { e.stopPropagation(); onTagClick() }}
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
title="Tag issue"
aria-label="Tag issue"
>
🏷
</button>
)}
</div>
)
}
function LoadingMore() {
return (
<div className="flex justify-center py-6">
<div
className="w-6 h-6 rounded-full border-2 animate-spin"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}
/>
</div>
)
}
function LoadingGrid() {
return (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
<div className="p-2 space-y-1">
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '75%' }} />
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
</div>
</div>
))}
</div>
)
}

View File

@@ -1,21 +1,138 @@
'use client'
import { useEffect, useRef } from 'react'
import type { Game } from '@/types'
import TagSelector from '@/components/tags/TagSelector'
import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game, GameFile, GamePlatform } from '@/types'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
// Import SVG icons
import WindowsIcon from '@/app/icons/windows.svg'
import LinuxIcon from '@/app/icons/linux.svg'
import MacosIcon from '@/app/icons/mac.svg'
import AndroidIcon from '@/app/icons/android.svg'
// Update the PLATFORM_LABELS to include android
const PLATFORM_LABELS: Record<GamePlatform, string> = {
windows: 'WIN',
linux: 'LIN',
macos: 'MAC',
android: 'AND',
}
const PLATFORM_COLORS: Record<GamePlatform, string> = {
windows: '#85c0ec',
linux: '#efd27b',
macos: '#b0b0b7',
android: '#9ee0ca',
}
interface Props {
game: Game
libraryId: string
onClose: () => void
onPrev?: () => void
onNext?: () => void
onTagsChanged?: () => void
onCoverUploaded?: () => void
onDeleted?: (gameId: string) => void
readOnly?: boolean
}
export default function GameDetailModal({ game, libraryId, onClose }: Props) {
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const screenshotInputRef = useRef<HTMLInputElement>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [editingImages, setEditingImages] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [renaming, setRenaming] = useState(false)
const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const [aiDescription, setAiDescription] = useState<string | null>(null)
// Screenshots state
const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([])
const [screenshotsLoading, setScreenshotsLoading] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null)
const [uploadingCount, setUploadingCount] = useState(0)
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
const fetchScreenshots = useCallback(() => {
setScreenshotsLoading(true)
fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`)
.then((r) => r.json())
.then((data) => setScreenshots(data.screenshots ?? []))
.catch(() => {})
.finally(() => setScreenshotsLoading(false))
}, [libraryId, game.id])
useEffect(() => { fetchScreenshots() }, [fetchScreenshots])
useEffect(() => {
if (!game.item_key) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(game.item_key)}`)
.then((r) => r.json())
.then((d: { aiDescription: string | null }) => setAiDescription(d.aiDescription ?? null))
.catch(() => {})
}, [game.item_key])
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (files.length === 0) return
e.target.value = ''
for (const file of files) {
setUploadingCount((n) => n + 1)
const form = new FormData()
form.append('screenshot', file)
try {
await fetch(
`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`,
{ method: 'POST', body: form }
)
} catch { /* ignore */ }
finally { setUploadingCount((n) => n - 1) }
}
fetchScreenshots()
}
const handleDeleteScreenshot = async (filename: string) => {
setDeletingScreenshot(filename)
try {
await fetch(
`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}&filename=${encodeURIComponent(filename)}`,
{ method: 'DELETE' }
)
} catch { /* ignore */ }
finally {
setDeletingScreenshot(null)
fetchScreenshots()
}
}
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (lightboxIndex !== null) {
if (e.key === 'Escape') { setLightboxIndex(null); return }
if (e.key === 'ArrowLeft') { setLightboxIndex((i) => (i! > 0 ? i! - 1 : i)); return }
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
return
}
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return }
if (renaming) { setRenaming(false); return }
if (editingImages) { setEditingImages(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose()
}
}
document.addEventListener('keydown', handleKey)
document.body.style.overflow = 'hidden'
@@ -23,53 +140,70 @@ export default function GameDetailModal({ game, libraryId, onClose }: Props) {
document.removeEventListener('keydown', handleKey)
document.body.style.overflow = ''
}
}, [onClose])
}, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
// Close menu on outside click
useEffect(() => {
if (!menuOpen) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [menuOpen])
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose()
}
const downloadHref = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(game.zipPath)}`
const [clientPlatform, setClientPlatform] = useState<GamePlatform | null>(null)
useEffect(() => {
const p = navigator.platform.toLowerCase()
if (p.startsWith('win')) setClientPlatform('windows')
else if (p.startsWith('mac') || p.includes('iphone') || p.includes('ipad')) setClientPlatform('macos')
else setClientPlatform('linux')
}, [])
const fileDownloadUrl = (filePath: string) =>
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(filePath)}`
const heroImage = game.wideCoverUrl ?? game.coverUrl
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
onClick={handleOverlayClick}
>
<div
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
aria-label="Close"
>
</button>
{/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
{/* Wide cover / cover hero */}
{/* ── Left pane — relative container for floating controls ── */}
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
{/* Scrollable card area */}
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
<div
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{editingImages ? (
<ImageEditor
game={game}
libraryId={libraryId}
onBack={() => setEditingImages(false)}
onUploaded={onCoverUploaded}
/>
) : (
<>
{/* Hero image */}
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
{game.wideCoverUrl ? (
{heroImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={game.wideCoverUrl}
alt={`${game.title} wide cover`}
className="w-full object-cover max-h-64"
/>
) : game.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={game.coverUrl}
alt={`${game.title} cover`}
className="w-full object-contain max-h-64"
/>
<img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
) : (
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
)}
@@ -77,11 +211,463 @@ export default function GameDetailModal({ game, libraryId, onClose }: Props) {
{/* Info */}
<div className="p-5">
<h2 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
{/* Title row with kebab menu */}
<div className="flex items-center gap-2 mb-2">
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
{game.title}
</h2>
{/* Kebab menu */}
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
<button
onClick={() => setMenuOpen((o) => !o)}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<button
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit images
</button>
<button
onClick={() => {
setMenuOpen(false)
setRenameName(decodeURIComponent(game.id))
setRenameError(null)
setRenaming(true)
}}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
{onDeleted && (
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete game
</button>
)}
</div>
)}
</div>}
</div>
{/* AI description (read-only) */}
{aiDescription && (
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
{aiDescription}
</p>
)}
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-4">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.() // triggers refetch
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}
if (e.key === 'Escape') setRenaming(false)
}}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={() => {
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }),
})
.then(async (res) => {
if (res.status === 409) { setRenameError((await res.json()).error); return }
if (!res.ok) throw new Error()
setRenaming(false)
onCoverUploaded?.()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{/* Delete confirmation banner */}
{confirming && (
<div
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this game and all its files?
</p>
<button
onClick={() => setConfirming(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Cancel
</button>
<button
onClick={() => {
setDeleting(true)
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
.then(() => onDeleted!(game.id))
.catch(() => setDeleting(false))
}}
disabled={deleting}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Assigned tags (read-only) above download */}
{game.item_key && (
<div className="mb-3">
<AssignedTagBadges itemKey={game.item_key} refreshKey={tagRefreshKey} />
</div>
)}
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
{/* Screenshots */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Screenshots
</p>
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{screenshotsLoading && screenshots.length === 0 ? (
<div className="flex-shrink-0 w-36 aspect-video rounded-lg animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
) : (
<>
{screenshots.map((shot, idx) => (
<div
key={shot.filename}
className="group relative flex-shrink-0 w-36 aspect-video rounded-lg overflow-hidden cursor-pointer"
style={{ backgroundColor: 'var(--border)' }}
onClick={() => setLightboxIndex(idx)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={shot.thumbnailUrl} alt={`Screenshot ${idx + 1}`} className="w-full h-full object-cover" />
{deletingScreenshot !== shot.filename && (
<button
onClick={(e) => { e.stopPropagation(); handleDeleteScreenshot(shot.filename) }}
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
aria-label="Delete screenshot"
>
</button>
)}
{deletingScreenshot === shot.filename && (
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<span className="text-xs text-white">Deleting</span>
</div>
)}
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Uploading</span>
</div>
))}
<button
onClick={() => screenshotInputRef.current?.click()}
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
aria-label="Add screenshot"
>
<span className="text-xl">+</span>
</button>
</>
)}
</div>
<input
ref={screenshotInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleScreenshotUpload}
/>
</div>
</div>
</>
)}
</div>
</div>
{/* Floating controls — tag + close */}
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{game.item_key && !showTagPanel && (
<button
onClick={() => setShowTagPanel(true)}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div>
{/* Prev / Next */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && (
<MediaTagPanel
itemKey={game.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
</div>
{/* Screenshot lightbox (z-60, sits above the modal) */}
{lightboxIndex !== null && (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.92)', zIndex: 60 }}
onClick={() => setLightboxIndex(null)}
>
<div
className="relative flex items-center justify-center w-full h-full p-8"
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={screenshots[lightboxIndex].url}
alt={`Screenshot ${lightboxIndex + 1}`}
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
/>
{/* Close */}
<button
onClick={() => setLightboxIndex(null)}
className="absolute top-4 right-4 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
aria-label="Close"
>
</button>
{/* Prev */}
{lightboxIndex > 0 && (
<button
onClick={() => setLightboxIndex((i) => i! - 1)}
className="absolute left-4 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
aria-label="Previous screenshot"
>
</button>
)}
{/* Next */}
{lightboxIndex < screenshots.length - 1 && (
<button
onClick={() => setLightboxIndex((i) => i! + 1)}
className="absolute right-4 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
aria-label="Next screenshot"
>
</button>
)}
{/* Counter */}
<div
className="absolute bottom-4 left-1/2 -translate-x-1/2 text-xs px-3 py-1 rounded-full"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'rgba(255,255,255,0.7)' }}
>
{lightboxIndex + 1} / {screenshots.length}
</div>
</div>
</div>
)}
</div>
)
}
// ─── Download Button ──────────────────────────────────────────────────────────
const PLATFORM_ICONS: Record<GamePlatform, string> = {
windows: (typeof WindowsIcon === 'string' ? WindowsIcon : (WindowsIcon as { src: string }).src),
linux: (typeof LinuxIcon === 'string' ? LinuxIcon : (LinuxIcon as { src: string }).src),
macos: (typeof MacosIcon === 'string' ? MacosIcon : (MacosIcon as { src: string }).src),
android: (typeof AndroidIcon === 'string' ? AndroidIcon : (AndroidIcon as { src: string }).src),
}
function PlatformPill({ platform }: { platform: GamePlatform }) {
const src = PLATFORM_ICONS[platform]
return (
<span
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none flex-shrink-0 flex items-center gap-1"
style={{ backgroundColor: PLATFORM_COLORS[platform], color: '#fff' }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
{src && <img src={src} alt="" width={14} height={14} aria-hidden="true" />}
<span className="sr-only">{PLATFORM_LABELS[platform]}</span>
</span>
)
}
function DownloadButton({
gameFiles,
clientPlatform,
downloadUrl,
}: {
gameFiles: GameFile[]
clientPlatform: GamePlatform | null
downloadUrl: (filePath: string) => string
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const close = useCallback(() => setOpen(false), [])
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) close()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open, close])
if (gameFiles.length === 0) return null
// Pick primary: first file matching clientPlatform, or first overall
const primary =
(clientPlatform ? gameFiles.find((f) => f.platform === clientPlatform) : null) ??
gameFiles[0]
if (gameFiles.length === 1) {
return (
<a
href={downloadHref}
href={downloadUrl(primary.path)}
download
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
@@ -89,18 +675,244 @@ export default function GameDetailModal({ game, libraryId, onClose }: Props) {
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Download .zip
<span className="truncate">{primary.filename}</span>
<span className="justify-right flex-shrink-0"><PlatformPill platform={primary.platform} /></span>
</a>
)
}
return (
<div className="relative" ref={ref}>
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
{/* Primary download */}
<a
href={downloadUrl(primary.path)}
download
className="flex items-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors min-w-0"
style={{ color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
<span className="flex-shrink-0"></span>
<span className="truncate">{primary.filename}</span>
<span className="justify-right flex-shrink-0"><PlatformPill platform={primary.platform} /></span>
</a>
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector mediaKey={`${libraryId}:${game.id}`} />
{/* Divider */}
<div style={{ width: '1px', backgroundColor: 'rgba(255,255,255,0.25)' }} />
{/* Dropdown toggle */}
<button
onClick={() => setOpen((o) => !o)}
className="px-3 flex items-center justify-center text-sm transition-colors flex-shrink-0"
style={{ color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="Show all downloads"
>
</button>
</div>
{open && (
<div
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{gameFiles.map((file) => (
<a
key={file.path}
href={downloadUrl(file.path)}
download
onClick={close}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
<span style={{ color: 'var(--text-secondary)' }} className="flex-shrink-0"></span>
<span className="truncate">{file.filename}</span>
<PlatformPill platform={file.platform} />
</a>
))}
</div>
)}
</div>
)
}
// ─── Image Editor ─────────────────────────────────────────────────────────────
interface ImageEditorProps {
game: Game
libraryId: string
onBack: () => void
onUploaded?: () => void
}
function ImageEditor({ game, libraryId, onBack, onUploaded }: ImageEditorProps) {
return (
<div className="p-5">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={onBack}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors flex-shrink-0"
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
aria-label="Back"
>
</button>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
Edit Images
</h2>
</div>
<div className="flex flex-col gap-6">
<ImageSlot
label="Cover"
description="Portrait artwork (3:4)"
currentUrl={game.coverUrl}
fallback="🎮"
aspectClass="aspect-[3/4]"
libraryId={libraryId}
itemId={game.id}
coverType="cover"
onUploaded={onUploaded}
/>
<ImageSlot
label="Wide Cover"
description="Landscape hero image"
currentUrl={game.wideCoverUrl}
fallback="🎮"
aspectClass="aspect-video"
libraryId={libraryId}
itemId={game.id}
coverType="widecover"
onUploaded={onUploaded}
/>
</div>
</div>
)
}
// ─── Image Slot ───────────────────────────────────────────────────────────────
interface ImageSlotProps {
label: string
description: string
currentUrl: string | null
fallback: string
aspectClass: string
libraryId: string
itemId: string
coverType: 'cover' | 'widecover'
onUploaded?: () => void
}
function ImageSlot({
label, description, currentUrl, fallback, aspectClass,
libraryId, itemId, coverType, onUploaded,
}: ImageSlotProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [preview, setPreview] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const displayUrl = preview ?? currentUrl
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Show local preview immediately
const objectUrl = URL.createObjectURL(file)
setPreview(objectUrl)
setError(null)
setUploading(true)
const form = new FormData()
form.append('cover', file)
try {
const res = await fetch(
`/api/game-cover?libraryId=${encodeURIComponent(libraryId)}&itemId=${encodeURIComponent(itemId)}&coverType=${coverType}`,
{ method: 'POST', body: form }
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error ?? 'Upload failed.')
setPreview(null)
} else {
onUploaded?.()
}
} catch {
setError('Network error.')
setPreview(null)
} finally {
setUploading(false)
e.target.value = ''
}
}
return (
<div>
<div className="flex items-baseline justify-between mb-2">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{description}</p>
</div>
<div className="flex items-start gap-4">
{/* Preview */}
<div
className={`${aspectClass} rounded-lg overflow-hidden flex-shrink-0 relative`}
style={{
width: coverType === 'cover' ? '80px' : '160px',
backgroundColor: 'var(--border)',
border: '1px solid var(--border)',
}}
>
{displayUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={displayUrl} alt={label} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">{fallback}</div>
)}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<span className="text-xs text-white">Saving</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 text-left"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
>
{currentUrl || preview ? 'Replace image' : 'Upload image'}
</button>
{error && (
<p className="text-xs" style={{ color: '#fca5a5' }}>{error}</p>
)}
</div>
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleChange}
/>
</div>
)
}

View File

@@ -1,25 +1,114 @@
'use client'
import { useEffect, useState } from 'react'
import type { Game } from '@/types'
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import type { Game, GamePlatform, GameSeries, RatingOperator } from '@/types'
import { useDebounce } from '@/hooks/useDebounce'
import GameDetailModal from './GameDetailModal'
import FilterPanel from '@/components/FilterPanel'
// Import SVG icons
import WindowsIcon from '@/app/icons/windows.svg'
import LinuxIcon from '@/app/icons/linux.svg'
import MacosIcon from '@/app/icons/mac.svg'
import AndroidIcon from '@/app/icons/android.svg'
const PLATFORM_LABELS: Record<GamePlatform, string> = {
windows: 'WIN',
linux: 'LIN',
macos: 'MAC',
android: 'AND',
}
const PLATFORM_COLORS: Record<GamePlatform, string> = {
windows: '#85c0ec',
linux: '#efd27b',
macos: '#b0b0b7',
android: '#9ee0ca',
}
const PLATFORM_ICONS: Record<GamePlatform, string> = {
windows: (typeof WindowsIcon === 'string' ? WindowsIcon : (WindowsIcon as { src: string }).src),
linux: (typeof LinuxIcon === 'string' ? LinuxIcon : (LinuxIcon as { src: string }).src),
macos: (typeof MacosIcon === 'string' ? MacosIcon : (MacosIcon as { src: string }).src),
android: (typeof AndroidIcon === 'string' ? AndroidIcon : (AndroidIcon as { src: string }).src),
}
function getPlatformIcon(platform: GamePlatform) {
const src = PLATFORM_ICONS[platform]
if (!src) return null
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt="" width={14} height={14} aria-hidden="true" />
}
function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
if (platforms.length === 0) return null
return (
<div className="flex gap-1 flex-wrap">
{platforms.map((p) => (
<span
key={p}
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none flex items-center gap-1"
style={{ backgroundColor: PLATFORM_COLORS[p], color: '#fff' }}
>
{getPlatformIcon(p)}
<span className="sr-only">{PLATFORM_LABELS[p]}</span>
</span>
))}
</div>
)
}
interface Props {
libraryId: string
readOnly?: boolean
}
export default function GamesView({ libraryId }: Props) {
const [games, setGames] = useState<Game[]>([])
export default function GamesView({ libraryId, readOnly }: Props) {
const [items, setItems] = useState<(Game | GameSeries)[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedSeries, setSelectedSeries] = useState<GameSeries | null>(null)
const [selected, setSelected] = useState<Game | null>(null)
const selectedRef = useRef(selected)
selectedRef.current = selected
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [ratingValue, setRatingValue] = useState<number | null>(null)
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
const debouncedSearch = useDebounce(search, 200)
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [selectedGameIndex, setSelectedGameIndex] = useState<number | null>(null)
useEffect(() => {
const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => {
const next = new Set(prev)
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
return next
})
const fetchGames = useCallback((syncSelected = false) => {
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then((data) => {
setGames(data)
.then((data: (Game | GameSeries)[]) => {
setItems(data)
setLoading(false)
if (syncSelected && selectedRef.current) {
const id = selectedRef.current.id
// Search top-level games and inside series
let updated: Game | undefined
for (const item of data) {
if ('games' in item) {
updated = item.games.find((g) => g.id === id)
} else if (item.id === id) {
updated = item
}
if (updated) break
}
if (updated) setSelected(updated)
}
})
.catch(() => {
setError('Failed to load games')
@@ -27,22 +116,205 @@ export default function GamesView({ libraryId }: Props) {
})
}, [libraryId])
if (loading) return <LoadingGrid />
if (error) return <ErrorMessage message={error} />
if (games.length === 0) return <EmptyState />
useEffect(() => { fetchGames() }, [fetchGames])
const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setAssignments)
.catch(() => {})
}, [libraryId])
useEffect(() => { fetchAssignments() }, [fetchAssignments])
// Items shown in the current view level
const visibleItems: (Game | GameSeries)[] = selectedSeries
? selectedSeries.games
: items
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
if (value === ratingValue && operator === ratingOperator) {
setRatingValue(null)
} else {
setRatingValue(value)
setRatingOperator(operator)
}
}
const filtered = useMemo(() => visibleItems.filter((item) => {
if ('games' in item) {
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
const searchMatch =
item.title.toLowerCase().includes(q) ||
item.games.some((g) =>
g.title.toLowerCase().includes(q) ||
(g.aiDescription?.toLowerCase().includes(q) ?? false) ||
(g.extractedText?.toLowerCase().includes(q) ?? false) ||
(g.extractedTextTranslated?.toLowerCase().includes(q) ?? false)
)
if (!searchMatch) return false
}
if (selectedTagIds.size > 0) {
if (!item.games.some((g) => {
const gameTags = assignments[g.item_key!] ?? []
return [...selectedTagIds].every((id) => gameTags.includes(id))
})) return false
}
if (ratingValue !== null) {
if (!item.games.some((g) => {
const r = g.userRating
if (r === null) return false
if (ratingOperator === 'gte') return r >= ratingValue
if (ratingOperator === 'eq') return r === ratingValue
if (ratingOperator === 'lte') return r <= ratingValue
return false
})) return false
}
return true
}
// Standalone Game
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
const g = item as Game
if (![g.title, g.aiDescription, g.extractedText, g.extractedTextTranslated]
.some((f) => f?.toLowerCase().includes(q))) return false
}
if (selectedTagIds.size > 0) {
const gameTags = assignments[item.item_key!] ?? []
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
}
if (ratingValue !== null) {
const r = (item as Game).userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
}), [visibleItems, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
const filteredGames: Game[] = filtered.flatMap((item) =>
'games' in item ? item.games : [item as Game]
)
return (
<>
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{games.map((game) => (
<div className="flex items-center gap-2 mb-4">
<button
key={game.id}
onClick={() => setSelected(game)}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
onClick={() => setShowFilters((v) => !v)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
borderColor: 'var(--border)',
backgroundColor: 'var(--surface)',
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
>
Filters{filtersActive ? ' ●' : ''}
</button>
</div>
<div className="flex flex-col md:flex-row gap-6 md:items-start">
{showFilters && (
<div className="w-full md:w-52 md:flex-shrink-0">
<FilterPanel
libraryId={libraryId}
assignments={assignments}
search={search}
onSearchChange={setSearch}
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
ratingValue={ratingValue}
ratingOperator={ratingOperator}
onRatingChange={handleRatingChange}
/>
</div>
)}
<div className="flex-1 min-w-0">
{/* Breadcrumb when inside a series */}
{selectedSeries && (
<div className="flex items-center gap-2 mb-4 text-sm">
<button
onClick={() => { setSelectedSeries(null); setSearch('') }}
className="transition-colors"
style={{ color: 'var(--accent)' }}
>
All Games
</button>
<span style={{ color: 'var(--text-secondary)' }}>/</span>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedSeries.title}
</span>
</div>
)}
{loading ? (
<LoadingGrid />
) : error ? (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{error}
</div>
) : items.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
<p className="text-lg mb-1">No games found</p>
<p className="text-sm">Each game should be a folder containing a .zip file.</p>
</div>
) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filtered.map((item) =>
'games' in item ? (
<SeriesCard
key={item.id}
series={item}
onClick={() => { setSelectedSeries(item); setSearch('') }}
/>
) : (
<GameCard
key={item.id}
game={item}
onClick={() => { setSelected(item); setSelectedGameIndex(filteredGames.indexOf(item)) }}
/>
)
)}
</div>
)}
{selected && (
<GameDetailModal
game={selected}
libraryId={libraryId}
readOnly={readOnly}
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }
: undefined}
onNext={selectedGameIndex !== null && selectedGameIndex < filteredGames.length - 1
? () => { const g = filteredGames[selectedGameIndex + 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex + 1) }
: undefined}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onCoverUploaded={() => fetchGames(true)}
onDeleted={() => {
setSelected(null)
setSelectedGameIndex(null)
fetchGames()
fetchAssignments()
}}
/>
)}
</div>
</div>
</>
)
}
function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
return (
<button
onClick={onClick}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
@@ -55,34 +327,76 @@ export default function GamesView({ libraryId }: Props) {
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{game.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={game.coverUrl}
alt={game.title}
className="absolute inset-0 w-full h-full object-cover"
/>
<img src={game.coverUrl} alt={game.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">
🎮
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)}
{game.platforms.length > 0 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
<PlatformBadges platforms={game.platforms} />
</div>
)}
</div>
<div className="p-2">
<p
className="text-xs font-medium truncate leading-tight"
style={{ color: 'var(--text-primary)' }}
title={game.title}
>
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}>
{game.title}
</p>
</div>
</button>
))}
</div>
)
}
{selected && (
<GameDetailModal game={selected} libraryId={libraryId} onClose={() => setSelected(null)} />
function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) {
// Compute union of platforms across all games in the series
const seriesPlatforms: GamePlatform[] = [
...new Set(series.games.flatMap((g) => g.platforms)),
]
const resolvedCover = series.coverUrl ?? series.games[0]?.coverUrl ?? null
return (
<button
onClick={onClick}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{resolvedCover ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={resolvedCover} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)}
</>
{/* Platform badges (bottom-left) */}
{seriesPlatforms.length > 0 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
<PlatformBadges platforms={seriesPlatforms} />
</div>
)}
{/* Game count badge (bottom-right) */}
<div
className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-xs font-semibold"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
>
{series.games.length}
</div>
</div>
<div className="p-2">
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={series.title}>
{series.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
Series
</p>
</div>
</button>
)
}
@@ -100,20 +414,3 @@ function LoadingGrid() {
</div>
)
}
function ErrorMessage({ message }: { message: string }) {
return (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{message}
</div>
)
}
function EmptyState() {
return (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
<p className="text-lg mb-1">No games found</p>
<p className="text-sm">Each game should be a folder containing a .zip file.</p>
</div>
)
}

View File

@@ -1,64 +1,637 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
interface Props {
url: string
name: string
onClose: () => void
onPrev?: () => void
onNext?: () => void
itemKey?: string
onTagsChanged?: () => void
onAiTag?: () => Promise<void>
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
}
export default function ImageLightbox({ url, name, onClose }: Props) {
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal
const setShowTags = onShowTagsChange ?? setShowTagsLocal
// Text extraction state
const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null)
const [extracting, setExtracting] = useState(false)
const [extractPending, setExtractPending] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null)
const [retranslating, setRetranslating] = useState(false)
const [translatePending, setTranslatePending] = useState(false)
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
const [savingText, setSavingText] = useState(false)
const [sourceLanguage, setSourceLanguage] = useState('')
// Description state
const [aiDescription, setAiDescription] = useState<string | null>(null)
const [editedDescription, setEditedDescription] = useState<string>('')
const [savingDesc, setSavingDesc] = useState(false)
const [generatingDesc, setGeneratingDesc] = useState(false)
const [descPending, setDescPending] = useState(false)
const [descError, setDescError] = useState<string | null>(null)
// OCR settings
const [ocrMode, setOcrMode] = useState<string | null>(null)
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
// Text overlay state
const [showTextOverlay, setShowTextOverlay] = useState(false)
const [showOriginal, setShowOriginal] = useState(false)
// Polling ref
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const touchStartX = useRef<number | null>(null)
// Determine if this is an image file (for text extraction controls)
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
// Derived: what text to display in the overlay
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
// Fetch existing AI fields on mount / item change
const fetchAiFields = useCallback(() => {
if (!itemKey) return Promise.resolve()
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
})
.catch(() => {})
}, [itemKey])
useEffect(() => {
fetchAiFields()
fetch('/api/ai-settings/ocr')
.then((r) => r.json())
.then((d: { ocrMode: string; ocrLanguages: string }) => {
setOcrMode(d.ocrMode)
setDefaultOcrLanguages(d.ocrLanguages)
})
.catch(() => {})
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [fetchAiFields])
// Start polling fields every 2s until data changes or 5-min timeout
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null, snapshotDesc: string | null) => {
if (!itemKey) return
if (pollRef.current) clearInterval(pollRef.current)
const deadline = Date.now() + 5 * 60 * 1000
pollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(pollRef.current!)
pollRef.current = null
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null } = await r.json()
const textChanged = data.extractedText !== snapshotText
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
const descChanged = data.aiDescription !== snapshotDesc
if (textChanged || translationChanged || descChanged) {
clearInterval(pollRef.current!)
pollRef.current = null
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setAiDescription(data.aiDescription)
setEditedDescription(data.aiDescription ?? '')
setExtractPending(false)
setTranslatePending(false)
setDescPending(false)
}
} catch { /* ignore */ }
}, 2000)
}, [itemKey])
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') onPrev?.()
if (e.key === 'ArrowRight') onNext?.()
}
const handleTouchStart = (e: TouchEvent) => {
touchStartX.current = e.touches[0].clientX
}
const handleTouchEnd = (e: TouchEvent) => {
if (touchStartX.current === null) return
const delta = touchStartX.current - e.changedTouches[0].clientX
if (delta > 50) onNext?.()
else if (delta < -50) onPrev?.()
touchStartX.current = null
}
document.addEventListener('keydown', handleKey)
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKey)
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
document.body.style.overflow = ''
}
}, [onClose])
}, [onClose, onPrev, onNext])
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose()
}
const handleGenerateDescription = async () => {
if (!itemKey) return
setGeneratingDesc(true)
setDescError(null)
setDescPending(false)
try {
const res = await fetch('/api/ai-tagging/describe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (res.status === 202) {
setDescPending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
}
const { description } = await res.json()
setAiDescription(description)
} catch (err) {
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
setTimeout(() => setDescError(null), 4000)
} finally {
setGeneratingDesc(false)
}
}
const callExtract = async (modeOverride: string) => {
setExtracting(true)
setExtractError(null)
setExtractPending(false)
try {
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey,
ocrMode: modeOverride,
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
}),
})
if (res.status === 202) {
setExtractPending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
}
const result = await res.json()
setExtractedText(result.extractedText || null)
setEditedExtractedText(result.extractedText || '')
setTranslatedText(result.translatedText || null)
} catch (err) {
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
setTimeout(() => setExtractError(null), 4000)
} finally {
setExtracting(false)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 gap-3"
style={{ backgroundColor: 'rgba(0,0,0,0.9)' }}
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
onClick={handleOverlayClick}
>
{/* Toolbar */}
<div className="flex items-center justify-between w-full max-w-4xl">
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
aria-label="Close"
>
</button>
</div>
{/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : ''}`}>
{/* Image */}
{/* ── Media pane — always full when no panel, flex-1 when panel open ── */}
<div className="relative flex-1 min-h-0 min-w-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name}
className="max-w-full max-h-[80vh] object-contain rounded-lg"
className="absolute inset-0 w-full h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{/* Prev / Next */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
className="absolute bottom-16 left-4 right-4 z-10 rounded-xl p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
{extractedText && translatedText && (
<div className="flex justify-end mb-2">
<button
onClick={() => setShowOriginal((v) => !v)}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation' : 'Show Original'}
</button>
</div>
)}
<p className="text-sm whitespace-pre-wrap" style={{ color: 'rgba(255,255,255,0.9)' }}>
{displayText}
</p>
</div>
)}
{/* ── Floating controls ── */}
{/* Filename pill — bottom-left */}
<div
className="absolute bottom-4 left-4 max-w-[55%] px-2.5 py-1 rounded-full pointer-events-none"
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
>
<span className="block text-xs truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
{name}
</span>
</div>
{/* Tags + Close — top-right */}
<div
className="absolute top-4 right-4 flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
{itemKey && !showTags && (
<button
onClick={() => { setShowTags(true); setShowTextOverlay(false) }}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
{!showTags && (
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close"
title="Close"
>
</button>
)}
</div>
{/* Text display button — bottom-right, hidden when panel open */}
{!showTags && extractedText && (
<button
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
className={`absolute bottom-4 right-4 ${smallBtn}`}
style={{
backgroundColor: showTextOverlay ? 'var(--accent)' : 'var(--surface)',
color: showTextOverlay ? '#fff' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!showTextOverlay) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label={showTextOverlay ? 'Hide text' : 'Show text'}
title="Display text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="15" y2="12"/>
<line x1="3" y1="18" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTags && (
<MediaTagPanel
itemKey={itemKey!}
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
>
{/* Description section */}
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Description
</p>
<button
onClick={handleGenerateDescription}
disabled={generatingDesc || descPending}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
color: descPending ? '#fff' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<textarea
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
placeholder="No description yet…"
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minHeight: '3.5rem',
maxHeight: '8rem',
fontFamily: 'inherit',
}}
/>
{editedDescription !== (aiDescription ?? '') && (
<button
onClick={async () => {
setSavingDesc(true)
try {
await fetch('/api/ai-tagging/fields', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
})
setAiDescription(editedDescription)
} finally {
setSavingDesc(false)
}
}}
disabled={savingDesc}
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingDesc ? '⟳ Saving…' : 'Save'}
</button>
)}
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
</div>
{/* Text extraction section — only for images */}
{isImage && (
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Text Extraction
</p>
<button
onClick={() => callExtract('llm')}
disabled={extracting || extractPending}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
color: extractPending ? '#fff' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="Extract text with AI"
title="Extract with AI (skips OCR)"
>
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!extracting && !extractPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
</button>
<input
type="text"
value={ocrLanguageInput}
onChange={(e) => setOcrLanguageInput(e.target.value)}
placeholder={defaultOcrLanguages}
className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 120,
}}
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
/>
</div>
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
{extractedText && (
<div className="flex flex-col gap-2">
<div>
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Extracted Text
</p>
<textarea
value={editedExtractedText}
onChange={(e) => setEditedExtractedText(e.target.value)}
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
style={{
backgroundColor: 'var(--background)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
minHeight: '4rem',
maxHeight: '10rem',
fontFamily: 'inherit',
}}
/>
{editedExtractedText !== extractedText && (
<button
onClick={async () => {
setSavingText(true)
try {
await fetch('/api/ai-tagging/fields', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
})
setExtractedText(editedExtractedText)
} finally {
setSavingText(false)
}
}}
disabled={savingText}
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingText ? '⟳ Saving…' : 'Save'}
</button>
)}
</div>
{translatedText && (
<div>
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Translation
</p>
<pre
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
>
{translatedText}
</pre>
</div>
)}
<div className="flex items-center gap-1.5 flex-wrap">
<input
type="text"
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
placeholder="Source lang…"
className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 100,
}}
/>
<button
onClick={async () => {
setRetranslating(true)
setTranslatePending(false)
try {
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
})
if (res.status === 202) {
setTranslatePending(true)
startPolling(extractedText, translatedText, aiDescription)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
}
const result = await res.json()
setTranslatedText(result.translatedText || null)
} catch {
// ignore
} finally {
setRetranslating(false)
}
}}
disabled={retranslating || translatePending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
color: translatePending ? '#fff' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!retranslating && !translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
if (!translatePending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button>
</div>
</div>
)}
</div>
)}
</MediaTagPanel>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,40 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import { useUserSettings } from '@/hooks/useUserSettings'
interface Props {
url: string
name: string
onClose: () => void
onPrev?: () => void
onNext?: () => void
itemKey?: string
onTagsChanged?: () => void
onAiTag?: () => Promise<void>
context?: 'mixed' | 'movies' | 'tv'
showTags?: boolean
onShowTagsChange?: (v: boolean) => void
readOnly?: boolean
}
export default function VideoPlayerModal({ url, name, onClose }: Props) {
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
const overlayRef = useRef<HTMLDivElement>(null)
const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal
const setShowTags = onShowTagsChange ?? setShowTagsLocal
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') onPrev?.()
if (e.key === 'ArrowRight') onNext?.()
}
document.addEventListener('keydown', handleKey)
document.body.style.overflow = 'hidden'
@@ -21,45 +42,113 @@ export default function VideoPlayerModal({ url, name, onClose }: Props) {
document.removeEventListener('keydown', handleKey)
document.body.style.overflow = ''
}
}, [onClose])
}, [onClose, onPrev, onNext])
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose()
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 gap-3"
style={{ backgroundColor: 'rgba(0,0,0,0.9)' }}
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh' }}
onClick={handleOverlayClick}
>
{/* Toolbar */}
<div className="flex items-center justify-between w-full max-w-4xl">
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
{/* Outer flex — row on md+, col on mobile when panel open */}
<div className={`flex h-full w-full ${showTags ? 'flex-col md:flex-row' : 'flex-row'}`}>
{/* ── Video column ── */}
<div className="flex flex-col flex-1 min-h-0 min-w-0 relative">
{/* Toolbar — scoped to this column's width */}
<div className="flex items-center justify-between px-3 py-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-sm truncate mr-2" style={{ color: 'var(--text-secondary)' }}>
{name}
</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
{itemKey && !showTags && (
<button
onClick={() => setShowTags(true)}
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Show tags"
title="Tags"
>
🏷
</button>
)}
{!showTags && (
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
className={smallBtn}
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
aria-label="Close"
title="Close"
>
</button>
)}
</div>
</div>
{/* Video */}
{/* Video area — single element, never remounts on panel toggle */}
<div className="relative flex-1 min-h-0" onClick={(e) => e.stopPropagation()}>
<video
key={url}
src={url}
controls
autoPlay
className="w-full max-w-4xl max-h-[80vh] rounded-lg"
autoPlay={autoPlay}
muted={muted}
loop={loop}
playsInline
className="w-full h-full object-contain"
style={{ backgroundColor: '#000' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Prev/Next — positioned relative to the full column height (incl. toolbar)
so they align with ImageLightbox's buttons which span the full viewport */}
{onPrev && (
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Previous"
>
</button>
)}
{onNext && (
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
aria-label="Next"
>
</button>
)}
</div>
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
{showTags && (
<MediaTagPanel
itemKey={itemKey!}
onHide={() => setShowTags(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={readOnly ? undefined : onAiTag}
readOnly={readOnly}
/>
)}
</div>
</div>
)
}

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