Compare commits
123 Commits
7560ad9094
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f1ad4f5dd | |||
|
|
e283d03e95 | ||
|
|
0e600e5f6c | ||
|
|
2cf8bc6d7d | ||
| da3ad97d51 | |||
|
|
b5d144c8cc | ||
|
|
d854bbe99b | ||
| d2057fb81c | |||
|
|
27430dbf52 | ||
|
|
bd028a7a5d | ||
|
|
8f8f8c3001 | ||
|
|
dee9356004 | ||
| 7d2ae7e95c | |||
|
|
cedc012733 | ||
| a9461f9ae4 | |||
|
|
a6d657d87d | ||
|
|
71a026f01e | ||
| fc9a7af7c3 | |||
|
|
b12decc802 | ||
|
|
6c6a35433c | ||
|
|
0842769125 | ||
| 95bcaf53be | |||
|
|
b0e9c9790c | ||
|
|
fbcd592609 | ||
| 7b76e3d900 | |||
|
|
2ea02b197b | ||
|
|
8f84da7e2f | ||
|
|
625e256944 | ||
| 152bc12427 | |||
|
|
345a05e42a | ||
|
|
0de839393a | ||
|
|
0ff3ed8ac9 | ||
|
|
b2e9df8ab8 | ||
| b774cba046 | |||
|
|
5b5c3453d2 | ||
|
|
37dcb79546 | ||
| c2135747b5 | |||
|
|
afcf740f63 | ||
|
|
dae33a36bc | ||
|
|
a379e94bce | ||
|
|
0b03b937e0 | ||
|
|
19756c9eab | ||
| b25774d928 | |||
|
|
db2e446ef4 | ||
|
|
96cfb8aae7 | ||
|
|
d754f85717 | ||
| 9d73459f48 | |||
|
|
9b2690f639 | ||
|
|
1350a6f94b | ||
|
|
2fc9a34626 | ||
| 236f168eeb | |||
|
|
fea55594d0 | ||
|
|
8557c80c52 | ||
|
|
68b1ed94ea | ||
|
|
e31a9667ef | ||
| c454d020da | |||
|
|
b0fc275a52 | ||
|
|
cd9a83ea90 | ||
|
|
5ba73b2e56 | ||
| 2b51f72f96 | |||
|
|
efaff8ca1b | ||
|
|
89ac22e9d1 | ||
|
|
b0d146679f | ||
|
|
887cc05901 | ||
| afb9540df2 | |||
|
|
5ac4b3bd8a | ||
|
|
470f34c985 | ||
|
|
7e284383b4 | ||
| 60790a3af1 | |||
|
|
6c769b457f | ||
|
|
ad9920a448 | ||
|
|
732e9134c3 | ||
|
|
0238dbda7a | ||
| 9bff0f848a | |||
|
|
aae41e9803 | ||
| 7e9ba6e014 | |||
|
|
0091606e4d | ||
|
|
080cc011b9 | ||
|
|
d3e1bf049b | ||
| 625539f35e | |||
|
|
84c65c7964 | ||
|
|
53205d4a19 | ||
| ebc35d7184 | |||
|
|
768c49ef00 | ||
| 1ca90184f5 | |||
|
|
6c2443fa2c | ||
|
|
5d4d11512d | ||
|
|
6f86750a99 | ||
| 390ce8fcc6 | |||
|
|
f08950f456 | ||
|
|
4d75e74cab | ||
| e5953100d6 | |||
|
|
58c5e424d9 | ||
|
|
38a6886863 | ||
|
|
819748d1ff | ||
| 01a4a1c0b7 | |||
|
|
5d27ba351b | ||
| 957d884903 | |||
|
|
6b5ff81654 | ||
| 80d922263e | |||
| e46c8d026f | |||
|
|
87a90a88bc | ||
| ffaa460324 | |||
|
|
0c234b691e | ||
|
|
4f54a7c888 | ||
|
|
334d62e3b3 | ||
| 612e20da8e | |||
|
|
6858c1e8cf | ||
|
|
8829188c58 | ||
| c87a9b33bb | |||
|
|
5b5503b7a6 | ||
|
|
eecee9bc5f | ||
| f0666c0649 | |||
|
|
ca4bea084a | ||
|
|
bc77abbd8b | ||
|
|
427aade21a | ||
| 2ba887dba6 | |||
|
|
122d7aa332 | ||
|
|
b254907cca | ||
|
|
e8b317f99d | ||
|
|
b3abc7ee4c | ||
|
|
c3adf11e3e | ||
|
|
948b7390d3 |
@@ -14,7 +14,6 @@ jobs:
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: gitea.lan
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
@@ -24,18 +23,18 @@ jobs:
|
||||
SHORT_HASH: ${{ github.sha }}
|
||||
run: |
|
||||
# Build the image with the commit hash tag
|
||||
docker build --build-arg BUILD_IDENTIFIER=${SHORT_HASH:0:5} -t gitea.lan/gpatti/mediaLoreweb-app:${BRANCH_NAME}-${SHORT_HASH:0:5} .
|
||||
docker build --build-arg BUILD_IDENTIFIER=${SHORT_HASH:0:5} -t gpatti/medialoreweb-app:${BRANCH_NAME} .
|
||||
|
||||
# Tag the same image as "latest"
|
||||
docker tag gitea.lan/gpatti/mediaLoreweb-app:${BRANCH_NAME}-${SHORT_HASH:0:5} gitea.lan/gpatti/mediaLoreweb-app:${BRANCH_NAME}-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 gitea.lan/gpatti/mediaLoreweb-app:${BRANCH_NAME}-${SHORT_HASH:0:5}
|
||||
docker push gitea.lan/gpatti/mediaLoreweb-app:${BRANCH_NAME}-latest
|
||||
docker push gpatti/medialoreweb-app:${BRANCH_NAME}
|
||||
docker push gpatti/medialoreweb-app:${BRANCH_NAME}-latest
|
||||
|
||||
- name: Log out from registry
|
||||
if: always()
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,3 +8,6 @@ medialore.db
|
||||
medialore.db-shm
|
||||
medialore.db-wal
|
||||
tsconfig.tsbuildinfo
|
||||
.session_secret
|
||||
.vscode/
|
||||
*.traineddata
|
||||
1
.session_secret
Normal file
1
.session_secret
Normal file
@@ -0,0 +1 @@
|
||||
e63d0c98a24a2c5a25438b22b426d27e8ca9e3889e8d7a344b556ac48c5d51f3
|
||||
@@ -45,6 +45,11 @@ COPY --from=builder /app/.next/static ./.next/static
|
||||
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
|
||||
|
||||
233
README.md
233
README.md
@@ -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)
|
||||
│ │ ├── page.tsx # Home — library cards (redirects to /manage if empty)
|
||||
│ │ ├── 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
|
||||
│ │ ├── games.ts # Games library scanner
|
||||
│ │ ├── files.ts # Mixed library directory scanner
|
||||
│ │ └── thumbnails.ts # Thumbnail cache + generation (sharp / ffmpeg)
|
||||
│ │ ├── 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
|
||||
│ │ ├── 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.
|
||||
Libraries are managed through the **Manage Libraries** screen at `/manage` in the app.
|
||||
|
||||
When you add a library via the UI, you provide:
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Name | Display name shown in the UI |
|
||||
| Path | Absolute or project-relative path to the library root folder on disk |
|
||||
| Type | `games`, `movies`, `tv`, or `mixed` |
|
||||
|
||||
| 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` |
|
||||
|
||||
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
|
||||
|
||||
@@ -6,13 +6,18 @@ services:
|
||||
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:
|
||||
# Runtime data — must map to /app/ since process.cwd() = /app in the container
|
||||
- ./medialore.db:/app/medialore.db
|
||||
- ./.thumbnails:/app/.thumbnails
|
||||
# Library config — mounted as a directory so the atomic rename in the API works.
|
||||
# A single-file bind-mount causes EBUSY on rename() because .tmp and the target
|
||||
# end up on different devices. Initialize before first run:
|
||||
# Library config, database, and session secret — all in one directory volume.
|
||||
# Initialize before first run:
|
||||
# mkdir -p config && echo '[]' > config/libraries.json
|
||||
- ./config:/config
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
serverExternalPackages: ['better-sqlite3', 'sharp'],
|
||||
serverExternalPackages: ['better-sqlite3', 'sharp', 'tesseract.js'],
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
1393
package-lock.json
generated
1393
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -12,16 +12,26 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
|
||||
63
src/app/api/ai-jobs/route.ts
Normal file
63
src/app/api/ai-jobs/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
48
src/app/api/ai-settings/library/[id]/route.ts
Normal file
48
src/app/api/ai-settings/library/[id]/route.ts
Normal 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))
|
||||
}
|
||||
11
src/app/api/ai-settings/ocr/route.ts
Normal file
11
src/app/api/ai-settings/ocr/route.ts
Normal 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 })
|
||||
}
|
||||
13
src/app/api/ai-settings/retag/route.ts
Normal file
13
src/app/api/ai-settings/retag/route.ts
Normal 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 })
|
||||
}
|
||||
97
src/app/api/ai-settings/route.ts
Normal file
97
src/app/api/ai-settings/route.ts
Normal 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() })
|
||||
}
|
||||
47
src/app/api/ai-settings/test/route.ts
Normal file
47
src/app/api/ai-settings/test/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
27
src/app/api/ai-tagging/describe-bulk/route.ts
Normal file
27
src/app/api/ai-tagging/describe-bulk/route.ts
Normal 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 })
|
||||
}
|
||||
24
src/app/api/ai-tagging/describe/route.ts
Normal file
24
src/app/api/ai-tagging/describe/route.ts
Normal 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 })
|
||||
}
|
||||
25
src/app/api/ai-tagging/extract-text-bulk/route.ts
Normal file
25
src/app/api/ai-tagging/extract-text-bulk/route.ts
Normal 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 })
|
||||
}
|
||||
33
src/app/api/ai-tagging/extract-text/route.ts
Normal file
33
src/app/api/ai-tagging/extract-text/route.ts
Normal 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 })
|
||||
}
|
||||
55
src/app/api/ai-tagging/fields/route.ts
Normal file
55
src/app/api/ai-tagging/fields/route.ts
Normal 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 })
|
||||
}
|
||||
24
src/app/api/ai-tagging/route.ts
Normal file
24
src/app/api/ai-tagging/route.ts
Normal 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 })
|
||||
}
|
||||
36
src/app/api/ai-tagging/translate-bulk/route.ts
Normal file
36
src/app/api/ai-tagging/translate-bulk/route.ts
Normal 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 })
|
||||
}
|
||||
24
src/app/api/ai-tagging/translate/route.ts
Normal file
24
src/app/api/ai-tagging/translate/route.ts
Normal 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 })
|
||||
}
|
||||
36
src/app/api/auth/login/route.ts
Normal file
36
src/app/api/auth/login/route.ts
Normal 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
|
||||
}
|
||||
9
src/app/api/auth/logout/route.ts
Normal file
9
src/app/api/auth/logout/route.ts
Normal 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
|
||||
}
|
||||
59
src/app/api/auth/register/route.ts
Normal file
59
src/app/api/auth/register/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
71
src/app/api/comics/page/route.ts
Normal file
71
src/app/api/comics/page/route.ts
Normal 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
117
src/app/api/comics/route.ts
Normal 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 })
|
||||
}
|
||||
26
src/app/api/comics/series-issue-tags/route.ts
Normal file
26
src/app/api/comics/series-issue-tags/route.ts
Normal 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))
|
||||
}
|
||||
@@ -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))}"`
|
||||
|
||||
|
||||
115
src/app/api/game-cover/route.ts
Normal file
115
src/app/api/game-cover/route.ts
Normal 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 })
|
||||
}
|
||||
177
src/app/api/game-screenshots/route.ts
Normal file
177
src/app/api/game-screenshots/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
16
src/app/api/imported-tags/route.ts
Normal file
16
src/app/api/imported-tags/route.ts
Normal 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)
|
||||
}
|
||||
70
src/app/api/libraries/[id]/bulk-rename/route.ts
Normal file
70
src/app/api/libraries/[id]/bulk-rename/route.ts
Normal 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 })
|
||||
}
|
||||
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
34
src/app/api/libraries/[id]/import-metadata/route.ts
Normal file
34
src/app/api/libraries/[id]/import-metadata/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -12,9 +13,12 @@ function coverPath(id: string, ext: string) {
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
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) {
|
||||
@@ -39,6 +43,9 @@ 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) {
|
||||
@@ -85,9 +92,12 @@ export async function POST(
|
||||
}
|
||||
|
||||
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)
|
||||
if (!library) {
|
||||
|
||||
61
src/app/api/metadata/route.ts
Normal file
61
src/app/api/metadata/route.ts
Normal 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 })
|
||||
}
|
||||
70
src/app/api/movies/route.ts
Normal file
70
src/app/api/movies/route.ts
Normal 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 })
|
||||
}
|
||||
198
src/app/api/nfo-refresh/route.ts
Normal file
198
src/app/api/nfo-refresh/route.ts
Normal 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 })
|
||||
}
|
||||
64
src/app/api/ratings/route.ts
Normal file
64
src/app/api/ratings/route.ts
Normal 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 1–5' }, { 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
200
src/app/api/rename/route.ts
Normal 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 })
|
||||
}
|
||||
42
src/app/api/scan-settings/route.ts
Normal file
42
src/app/api/scan-settings/route.ts
Normal 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 })
|
||||
}
|
||||
28
src/app/api/scan/[id]/route.ts
Normal file
28
src/app/api/scan/[id]/route.ts
Normal 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
32
src/app/api/scan/route.ts
Normal 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 })
|
||||
}
|
||||
39
src/app/api/settings/route.ts
Normal file
39
src/app/api/settings/route.ts
Normal 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 })
|
||||
}
|
||||
21
src/app/api/tag-mappings/[id]/route.ts
Normal file
21
src/app/api/tag-mappings/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
44
src/app/api/tag-mappings/route.ts
Normal file
44
src/app/api/tag-mappings/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 category = updateCategory(id, name)
|
||||
return NextResponse.json(category)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
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
|
||||
@@ -15,6 +19,9 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTagAssignmentsForLibrary } from '@/lib/tags'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
if (!libraryId) return Response.json({ error: 'libraryId required' }, { status: 400 })
|
||||
return Response.json(getTagAssignmentsForLibrary(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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
console.error(`Thumbnail generation failed for ${filePath}:`, 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
114
src/app/api/tv/route.ts
Normal 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 })
|
||||
}
|
||||
14
src/app/api/tv/series-episode-tags/route.ts
Normal file
14
src/app/api/tv/series-episode-tags/route.ts
Normal 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))
|
||||
}
|
||||
75
src/app/api/users/[id]/permissions/route.ts
Normal file
75
src/app/api/users/[id]/permissions/route.ts
Normal 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 })
|
||||
}
|
||||
33
src/app/api/users/[id]/route.ts
Normal file
33
src/app/api/users/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
10
src/app/api/users/route.ts
Normal file
10
src/app/api/users/route.ts
Normal 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())
|
||||
}
|
||||
6
src/app/icons/android.svg
Normal file
6
src/app/icons/android.svg
Normal 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
6
src/app/icons/linux.svg
Normal 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
19
src/app/icons/mac.svg
Normal 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
19
src/app/icons/windows.svg
Normal 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 |
@@ -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</NavLink>
|
||||
{session.userId && (
|
||||
<HeaderNav username={session.username} isAdmin={session.role === 'admin'} />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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,23 +18,48 @@ 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>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<a href="/" className="text-sm transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
||||
Libraries
|
||||
</a>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{library.name}
|
||||
</span>
|
||||
</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
|
||||
</a>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||
<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
158
src/app/login/LoginForm.tsx
Normal 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
16
src/app/login/layout.tsx
Normal 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
14
src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1248
src/app/manage/ai-tagging/page.tsx
Normal file
1248
src/app/manage/ai-tagging/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
|
||||
@@ -5,18 +5,24 @@ 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)
|
||||
|
||||
@@ -101,8 +107,12 @@ function LibraryRow({
|
||||
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) {
|
||||
@@ -117,6 +127,26 @@ function LibraryRow({
|
||||
.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)
|
||||
@@ -203,6 +233,57 @@ function LibraryRow({
|
||||
|
||||
{/* 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}
|
||||
@@ -249,6 +330,216 @@ function LibraryRow({
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -282,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')
|
||||
@@ -327,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>
|
||||
@@ -406,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
|
||||
|
||||
287
src/app/manage/scanning/page.tsx
Normal file
287
src/app/manage/scanning/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
865
src/app/manage/tags/mappings/[id]/page.tsx
Normal file
865
src/app/manage/tags/mappings/[id]/page.tsx
Normal 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. "language" in "language: english") 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>
|
||||
)
|
||||
}
|
||||
@@ -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 “{mergeConflict.name}” already exists. This will merge all tags from
|
||||
“{category.name}” 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 }) {
|
||||
|
||||
460
src/app/manage/users/page.tsx
Normal file
460
src/app/manage/users/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getLibraries } from '@/lib/libraries'
|
||||
import { getServerSession } from '@/lib/auth'
|
||||
import { getLibrariesForUser } from '@/lib/users'
|
||||
import LibraryCard from '@/components/LibraryCard'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function HomePage() {
|
||||
const libraries = getLibraries()
|
||||
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) {
|
||||
redirect('/manage')
|
||||
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 (
|
||||
@@ -20,7 +35,7 @@ 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>
|
||||
</div>
|
||||
|
||||
136
src/app/settings/SettingsForm.tsx
Normal file
136
src/app/settings/SettingsForm.tsx
Normal 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
27
src/app/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
820
src/components/DoomScrollView.tsx
Normal file
820
src/components/DoomScrollView.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Tag, TagCategory } from '@/types'
|
||||
import type { Tag, TagCategory, RatingOperator } from '@/types'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
@@ -11,9 +11,24 @@ interface Props {
|
||||
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 }: Props) {
|
||||
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)
|
||||
@@ -53,6 +68,59 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
@@ -62,7 +130,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
||||
className="h-3 w-16 rounded animate-pulse"
|
||||
style={{ backgroundColor: 'var(--border)' }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||
{[50, 65, 42].map((w) => (
|
||||
<div
|
||||
key={w}
|
||||
@@ -84,7 +152,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
||||
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{cat.name}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||
{catTags.map((tag) => {
|
||||
const active = selectedTagIds.has(tag.id)
|
||||
return (
|
||||
|
||||
50
src/components/HeaderNav.tsx
Normal file
50
src/components/HeaderNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
53
src/components/ScanLibraryButton.tsx
Normal file
53
src/components/ScanLibraryButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
332
src/components/comics/ComicIssueView.tsx
Normal file
332
src/components/comics/ComicIssueView.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
233
src/components/comics/ComicSeriesView.tsx
Normal file
233
src/components/comics/ComicSeriesView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
561
src/components/comics/ComicsView.tsx
Normal file
561
src/components/comics/ComicsView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +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, onTagsChanged }: 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'
|
||||
@@ -24,84 +140,779 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
||||
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 */}
|
||||
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
||||
{game.wideCoverUrl ? (
|
||||
// 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
|
||||
{/* ── 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)' }}>
|
||||
{heroImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-5">
|
||||
{/* 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>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-5">
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
{game.title}
|
||||
</h2>
|
||||
<a
|
||||
href={downloadHref}
|
||||
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' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||
>
|
||||
<span>↓</span>
|
||||
Download .zip
|
||||
</a>
|
||||
{/* ── 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>
|
||||
|
||||
{/* 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}`} onTagsChanged={onTagsChanged} />
|
||||
{/* 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={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' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||
>
|
||||
<span>↓</span>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } 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'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
// 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',
|
||||
}
|
||||
|
||||
export default function GamesView({ libraryId }: Props) {
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
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, 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)
|
||||
|
||||
const toggleTag = (tagId: string) =>
|
||||
setSelectedTagIds((prev) => {
|
||||
@@ -26,12 +89,26 @@ export default function GamesView({ libraryId }: Props) {
|
||||
return next
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
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')
|
||||
@@ -39,6 +116,8 @@ export default function GamesView({ libraryId }: Props) {
|
||||
})
|
||||
}, [libraryId])
|
||||
|
||||
useEffect(() => { fetchGames() }, [fetchGames])
|
||||
|
||||
const fetchAssignments = useCallback(() => {
|
||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
@@ -48,80 +127,157 @@ export default function GamesView({ libraryId }: Props) {
|
||||
|
||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||
|
||||
const filtered = games.filter((game) => {
|
||||
if (search && !game.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||
// 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[`${libraryId}:${game.id}`] ?? []
|
||||
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="flex gap-6 items-start">
|
||||
<div className="w-52 flex-shrink-0">
|
||||
<FilterPanel
|
||||
libraryId={libraryId}
|
||||
assignments={assignments}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
selectedTagIds={selectedTagIds}
|
||||
onTagToggle={toggleTag}
|
||||
refreshKey={filterRefreshKey}
|
||||
/>
|
||||
<>
|
||||
<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-1 min-w-0">
|
||||
<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 ? (
|
||||
<ErrorMessage message={error} />
|
||||
) : games.length === 0 ? (
|
||||
<EmptyState />
|
||||
<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((game) => (
|
||||
<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"
|
||||
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)' }}>
|
||||
{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"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">
|
||||
🎮
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p
|
||||
className="text-xs font-medium truncate leading-tight"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
title={game.title}
|
||||
>
|
||||
{game.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -129,12 +285,118 @@ export default function GamesView({ libraryId }: Props) {
|
||||
<GameDetailModal
|
||||
game={selected}
|
||||
libraryId={libraryId}
|
||||
onClose={() => setSelected(null)}
|
||||
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>
|
||||
</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)'
|
||||
}}
|
||||
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)' }}>
|
||||
{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" />
|
||||
) : (
|
||||
<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}>
|
||||
{game.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
{/* 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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{/* ── 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="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
@@ -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)' }}>
|
||||
{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' : 'flex-row'}`}>
|
||||
|
||||
{/* Video */}
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
autoPlay
|
||||
className="w-full max-w-4xl max-h-[80vh] rounded-lg"
|
||||
style={{ backgroundColor: '#000' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{/* ── 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={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>
|
||||
</div>
|
||||
|
||||
{/* 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={autoPlay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
style={{ backgroundColor: '#000' }}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
582
src/components/movies/MovieDetailModal.tsx
Normal file
582
src/components/movies/MovieDetailModal.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Movie } from '@/types'
|
||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||
|
||||
interface Props {
|
||||
movie: Movie
|
||||
libraryId: string
|
||||
onClose: () => void
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
onTagsChanged?: () => void
|
||||
onDeleted: (movieId: string) => void
|
||||
onMetadataRefreshed?: () => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editForm, setEditForm] = useState({ title: '', year: '', plot: '', genres: '' })
|
||||
const [warnRefresh, setWarnRefresh] = 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 smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
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 (warnRefresh) { setWarnRefresh(false); return }
|
||||
if (editing) { setEditing(false); return }
|
||||
if (renaming) { setRenaming(false); return }
|
||||
if (showTagPanel) { setShowTagPanel(false); return }
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
|
||||
|
||||
// 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 videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}`
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setDeleting(true)
|
||||
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => onDeleted(movie.id))
|
||||
.catch(() => setDeleting(false))
|
||||
}
|
||||
|
||||
const doRefreshMetadata = () => {
|
||||
setRefreshing(true)
|
||||
setWarnRefresh(false)
|
||||
const itemKey = `${libraryId}:movie:${movie.id}`
|
||||
fetch(
|
||||
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=movie&itemKey=${encodeURIComponent(itemKey)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
.then(() => onMetadataRefreshed?.())
|
||||
.finally(() => setRefreshing(false))
|
||||
}
|
||||
|
||||
const handleRefreshMetadata = () => {
|
||||
setMenuOpen(false)
|
||||
if (movie.manuallyEdited) {
|
||||
setWarnRefresh(true)
|
||||
} else {
|
||||
doRefreshMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartEditing = () => {
|
||||
setMenuOpen(false)
|
||||
setEditForm({
|
||||
title: movie.title,
|
||||
year: movie.year?.toString() ?? '',
|
||||
plot: movie.plot ?? '',
|
||||
genres: movie.genres.join(', '),
|
||||
})
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const handleSaveMetadata = () => {
|
||||
setSaving(true)
|
||||
const genres = editForm.genres.split(',').map((g) => g.trim()).filter(Boolean)
|
||||
const yearNum = editForm.year ? parseInt(editForm.year, 10) : null
|
||||
fetch('/api/metadata', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
itemKey: movie.item_key,
|
||||
title: editForm.title,
|
||||
year: isNaN(yearNum as number) ? null : yearNum,
|
||||
plot: editForm.plot || null,
|
||||
genres,
|
||||
}),
|
||||
})
|
||||
.then(() => { setEditing(false); onMetadataRefreshed?.() })
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
const handleStartRename = () => {
|
||||
setMenuOpen(false)
|
||||
setRenameName(decodeURIComponent(movie.id))
|
||||
setRenameError(null)
|
||||
setRenaming(true)
|
||||
}
|
||||
|
||||
const handleRename = () => {
|
||||
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(movie.id),
|
||||
newName: trimmed,
|
||||
itemType: 'movie',
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 409) {
|
||||
const data = await res.json()
|
||||
setRenameError(data.error)
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error()
|
||||
setRenaming(false)
|
||||
onMetadataRefreshed?.()
|
||||
})
|
||||
.catch(() => setRenameError('Rename failed'))
|
||||
.finally(() => setRenameSaving(false))
|
||||
}
|
||||
|
||||
if (playing) {
|
||||
return (
|
||||
<VideoPlayerModal
|
||||
url={videoUrl}
|
||||
name={movie.title}
|
||||
itemKey={movie.item_key!}
|
||||
onTagsChanged={onTagsChanged}
|
||||
onClose={() => setPlaying(false)}
|
||||
onPrev={onPrev}
|
||||
onNext={onNext}
|
||||
context="movies"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const heroUrl = movie.backdropUrl ?? movie.posterUrl
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 overflow-hidden"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
||||
|
||||
{/* ── 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()}
|
||||
>
|
||||
|
||||
{/* Hero image */}
|
||||
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
||||
{heroUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={heroUrl}
|
||||
alt={movie.title}
|
||||
className="w-full object-cover max-h-64"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-5xl">🎬</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-5">
|
||||
{/* Title row with kebab menu */}
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
||||
{movie.title}
|
||||
</h2>
|
||||
{movie.year && (
|
||||
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{movie.year}
|
||||
</span>
|
||||
)}
|
||||
{/* Kebab menu */}
|
||||
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
|
||||
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={handleRefreshMetadata}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartEditing}
|
||||
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 metadata
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartRename}
|
||||
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>
|
||||
<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 movie
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Rename inline input */}
|
||||
{renaming && (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); 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={handleRename}
|
||||
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>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.title}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.year}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={editForm.plot}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.genres}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveMetadata}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Meta row */}
|
||||
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{movie.rating !== null && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
★ {movie.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{movie.runtime !== null && (
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{movie.runtime} min
|
||||
</span>
|
||||
)}
|
||||
{movie.genres.map((g) => (
|
||||
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{movie.plot && (
|
||||
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
{movie.plot}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* NFO refresh warning */}
|
||||
{warnRefresh && (
|
||||
<div
|
||||
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
|
||||
>
|
||||
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
|
||||
Refreshing from NFO will overwrite your manual edits.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setWarnRefresh(false)}
|
||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={doRefreshMetadata}
|
||||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
|
||||
>
|
||||
Overwrite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 movie 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={handleConfirmDelete}
|
||||
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 action buttons */}
|
||||
{movie.item_key && (
|
||||
<div className="mb-3">
|
||||
<AssignedTagBadges itemKey={movie.item_key} refreshKey={tagRefreshKey} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons row: Play + Download */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPlaying(true)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
||||
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)')}
|
||||
>
|
||||
<span>▶</span>
|
||||
Play
|
||||
</button>
|
||||
<a
|
||||
href={videoUrl}
|
||||
download
|
||||
className="flex items-center justify-center px-3 py-2.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Download"
|
||||
aria-label="Download"
|
||||
>
|
||||
↓
|
||||
</a>
|
||||
</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()}>
|
||||
{movie.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={movie.item_key!}
|
||||
onHide={() => setShowTagPanel(false)}
|
||||
onClose={onClose}
|
||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
262
src/components/movies/MoviesView.tsx
Normal file
262
src/components/movies/MoviesView.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import type { Movie, RatingOperator } from '@/types'
|
||||
import MovieDetailModal from './MovieDetailModal'
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
||||
import { isBrowserPlayable } from '@/lib/browser-media'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function MoviesView({ libraryId, readOnly }: Props) {
|
||||
const [movies, setMovies] = useState<Movie[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | 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 [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||
const [showFilters, setShowFilters] = useState(
|
||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||
)
|
||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||
|
||||
const toggleTag = (tagId: string) =>
|
||||
setSelectedTagIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
||||
return next
|
||||
})
|
||||
|
||||
const fetchMovies = useCallback(() => {
|
||||
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setMovies(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to load movies')
|
||||
setLoading(false)
|
||||
})
|
||||
}, [libraryId])
|
||||
|
||||
useEffect(() => { fetchMovies() }, [fetchMovies])
|
||||
|
||||
const fetchAssignments = useCallback(() => {
|
||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
.then(setAssignments)
|
||||
.catch(() => {})
|
||||
}, [libraryId])
|
||||
|
||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||
|
||||
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||
if (value === ratingValue && operator === ratingOperator) {
|
||||
setRatingValue(null)
|
||||
} else {
|
||||
setRatingValue(value)
|
||||
setRatingOperator(operator)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => movies.filter((movie) => {
|
||||
if (debouncedSearch) {
|
||||
const q = debouncedSearch.toLowerCase()
|
||||
if (![movie.title, movie.plot, movie.aiDescription, movie.extractedText, movie.extractedTextTranslated]
|
||||
.some((f) => f?.toLowerCase().includes(q))) return false
|
||||
}
|
||||
if (selectedTagIds.size > 0) {
|
||||
const movieTags = assignments[movie.item_key!] ?? []
|
||||
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
|
||||
}
|
||||
if (ratingValue !== null) {
|
||||
const r = movie.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
|
||||
}), [movies, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
||||
|
||||
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
|
||||
|
||||
const handleDeleted = (movieId: string) => {
|
||||
setSelectedIndex(null)
|
||||
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
||||
}
|
||||
|
||||
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||
|
||||
const handleDoomScroll = () => {
|
||||
// Use filtered movies — respects any active search/tag filters automatically
|
||||
const items: DoomScrollItem[] = filtered.filter((m) => isBrowserPlayable(m.videoPath)).map((m) => ({
|
||||
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(m.videoPath)}`,
|
||||
name: m.title,
|
||||
mediaType: 'video' as const,
|
||||
}))
|
||||
setDoomScrollItems(items)
|
||||
setDoomScrollActive(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{doomScrollActive && doomScrollItems.length > 0 && (
|
||||
<DoomScrollView
|
||||
items={doomScrollItems}
|
||||
videoContext="movies"
|
||||
onClose={() => setDoomScrollActive(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<button
|
||||
onClick={handleDoomScroll}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)' }}
|
||||
>
|
||||
Doom Scroll
|
||||
</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">
|
||||
{loading ? (
|
||||
<LoadingGrid />
|
||||
) : error ? (
|
||||
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : movies.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 movies found</p>
|
||||
<p className="text-sm">Each movie should be a folder containing a video 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((movie, idx) => (
|
||||
<button
|
||||
key={movie.id}
|
||||
onClick={() => setSelectedIndex(idx)}
|
||||
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-[2/3] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||
{movie.posterUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={movie.posterUrl}
|
||||
alt={movie.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">
|
||||
🎬
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p
|
||||
className="text-xs font-medium truncate leading-tight"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
title={movie.title}
|
||||
>
|
||||
{movie.title}
|
||||
</p>
|
||||
{movie.year && (
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{movie.year}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && selectedIndex !== null && (
|
||||
<MovieDetailModal
|
||||
movie={selected}
|
||||
libraryId={libraryId}
|
||||
readOnly={readOnly}
|
||||
onClose={() => setSelectedIndex(null)}
|
||||
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
||||
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
onDeleted={handleDeleted}
|
||||
onMetadataRefreshed={fetchMovies}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</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 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||
<div className="aspect-[2/3] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||||
<div className="p-2">
|
||||
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '70%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
src/components/tags/AssignedTagBadges.tsx
Normal file
73
src/components/tags/AssignedTagBadges.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Tag, TagCategory } from '@/types'
|
||||
|
||||
interface Props {
|
||||
itemKey: string
|
||||
refreshKey?: number
|
||||
}
|
||||
|
||||
export default function AssignedTagBadges({ itemKey, refreshKey }: Props) {
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [categories, setCategories] = useState<TagCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch(`/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { tags: Tag[]; categories: TagCategory[] }) => {
|
||||
setTags(data.tags ?? [])
|
||||
setCategories(data.categories ?? [])
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [itemKey, refreshKey])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[60, 80, 50].map((w) => (
|
||||
<div
|
||||
key={w}
|
||||
className="h-5 rounded-full animate-pulse"
|
||||
style={{ width: w, backgroundColor: 'var(--border)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tags.length === 0) return null
|
||||
|
||||
const catMap = new Map(categories.map((c) => [c.id, c.name]))
|
||||
|
||||
// Group by category
|
||||
const grouped = new Map<string | null, Tag[]>()
|
||||
for (const tag of tags) {
|
||||
const key = tag.categoryId ?? null
|
||||
if (!grouped.has(key)) grouped.set(key, [])
|
||||
grouped.get(key)!.push(tag)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Array.from(grouped.entries()).map(([catId, catTags]) => {
|
||||
const catName = catId ? catMap.get(catId) : null
|
||||
return catTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{catName && (
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{catName}:</span>
|
||||
)}
|
||||
{tag.name}
|
||||
</span>
|
||||
))
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user