From ff4c5562572b3a7ce801ce86d69d10c3e9fbd5b2 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 16:45:19 +0300 Subject: [PATCH 01/32] feat(webui): migrate stats page to react - move the stats route onto the React shell with Recharts-based visualizations - remove the global Chart.js include and add a local stats seed script for easier testing - keep parity coverage with route, API, and helper tests while preserving the legacy page layout --- webui/src/platform/shell/globals.d.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 8b043665..954686b8 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -62,6 +62,28 @@ declare global { showLoadingOverlay: (message?: string) => void; hideLoadingOverlay: () => void; }; + navigateToArtistDetail?: ( + artistId: string | number, + artistName: string, + sourceOverride?: string | null, + options?: Record, + ) => void; + playLibraryTrack?: ( + track: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + _stats_image?: string | null; + }, + albumTitle: string, + artistName: string, + ) => void | Promise; + startStream?: (searchResult: Record) => void | Promise; + showLoadingOverlay?: (message?: string) => void; + hideLoadingOverlay?: () => void; } } From 5680e52ceb929f9cb31c149adcd69ddebeb9de20 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 17:15:25 +0300 Subject: [PATCH 02/32] refactor(webui): route stats interop through shell bridge - move stats route legacy handoffs onto explicit SoulSyncWebShellBridge methods\n- stop relying on ad hoc window globals from React code for artist navigation and playback\n- update shell bridge tests and route test doubles to enforce the expanded bridge contract --- webui/src/platform/shell/globals.d.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 954686b8..8b043665 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -62,28 +62,6 @@ declare global { showLoadingOverlay: (message?: string) => void; hideLoadingOverlay: () => void; }; - navigateToArtistDetail?: ( - artistId: string | number, - artistName: string, - sourceOverride?: string | null, - options?: Record, - ) => void; - playLibraryTrack?: ( - track: { - id: string | number; - title: string; - file_path: string; - bitrate?: string | number | null; - artist_id?: string | number | null; - album_id?: string | number | null; - _stats_image?: string | null; - }, - albumTitle: string, - artistName: string, - ) => void | Promise; - startStream?: (searchResult: Record) => void | Promise; - showLoadingOverlay?: (message?: string) => void; - hideLoadingOverlay?: () => void; } } From 27fbc80e7a703db9bed52254d23dab803064232d Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 11:13:12 +0300 Subject: [PATCH 03/32] feat(webui): migrate import route to React - Move import page, tabs, workflow state, and route tests into React-owned route slices - Preserve shell gating, staging queries, album matching, singles matching, auto-import, and queue behavior - Add migration plan snapshot so cleanup/refinement can build on a stable baseline --- webui/docs/migration/README.md | 3 + webui/docs/migration/import-migration-plan.md | 315 ++++ .../docs/migration/page-migration-overview.md | 20 +- webui/index.html | 153 -- webui/package-lock.json | 32 +- webui/package.json | 3 +- webui/src/platform/shell/globals.d.ts | 7 + .../src/platform/shell/route-manifest.test.ts | 10 +- webui/src/platform/shell/route-manifest.ts | 8 +- webui/src/routeTree.gen.ts | 131 +- webui/src/routes/import/-import.api.ts | 293 +++ webui/src/routes/import/-import.helpers.ts | 286 +++ webui/src/routes/import/-import.store.ts | 236 +++ webui/src/routes/import/-import.types.ts | 226 +++ webui/src/routes/import/-route.test.tsx | 272 +++ .../routes/import/-ui/album-import-tab.tsx | 581 ++++++ .../src/routes/import/-ui/auto-import-tab.tsx | 580 ++++++ .../routes/import/-ui/import-page.module.css | 1645 +++++++++++++++++ webui/src/routes/import/-ui/import-page.tsx | 184 ++ webui/src/routes/import/-ui/import-shared.tsx | 106 ++ .../routes/import/-ui/singles-import-tab.tsx | 330 ++++ webui/src/routes/import/album.tsx | 15 + webui/src/routes/import/auto.tsx | 27 + webui/src/routes/import/index.tsx | 7 + webui/src/routes/import/route.tsx | 20 + webui/src/routes/import/singles.tsx | 7 + 26 files changed, 5326 insertions(+), 171 deletions(-) create mode 100644 webui/docs/migration/import-migration-plan.md create mode 100644 webui/src/routes/import/-import.api.ts create mode 100644 webui/src/routes/import/-import.helpers.ts create mode 100644 webui/src/routes/import/-import.store.ts create mode 100644 webui/src/routes/import/-import.types.ts create mode 100644 webui/src/routes/import/-route.test.tsx create mode 100644 webui/src/routes/import/-ui/album-import-tab.tsx create mode 100644 webui/src/routes/import/-ui/auto-import-tab.tsx create mode 100644 webui/src/routes/import/-ui/import-page.module.css create mode 100644 webui/src/routes/import/-ui/import-page.tsx create mode 100644 webui/src/routes/import/-ui/import-shared.tsx create mode 100644 webui/src/routes/import/-ui/singles-import-tab.tsx create mode 100644 webui/src/routes/import/album.tsx create mode 100644 webui/src/routes/import/auto.tsx create mode 100644 webui/src/routes/import/index.tsx create mode 100644 webui/src/routes/import/route.tsx create mode 100644 webui/src/routes/import/singles.tsx diff --git a/webui/docs/migration/README.md b/webui/docs/migration/README.md index a7a9d5e3..f54442a1 100644 --- a/webui/docs/migration/README.md +++ b/webui/docs/migration/README.md @@ -16,6 +16,9 @@ This folder is the home for React migration planning work inside `webui`. - cross-route risk assessment - [stats-migration-plan.md](./stats-migration-plan.md) - route-specific migration plan for `stats` +- [import-migration-plan.md](./import-migration-plan.md) + - route-specific migration plan for `import` + - implementation status and follow-up cleanup notes ## Naming Guidance diff --git a/webui/docs/migration/import-migration-plan.md b/webui/docs/migration/import-migration-plan.md new file mode 100644 index 00000000..9a5e4013 --- /dev/null +++ b/webui/docs/migration/import-migration-plan.md @@ -0,0 +1,315 @@ +# WebUI Import Migration Plan + +Snapshot date: 2026-05-15 + +## Status + +- Initial implementation completed on 2026-05-15. +- `import` is now React-owned in the shell route manifest. +- The legacy import page DOM has been removed from `webui/index.html`. +- Legacy import activation has been removed from `webui/static/init.js`. +- A React route slice now owns import rendering, tab state, album matching, singles matching, auto-import controls, and the client-side processing queue. +- The old import-specific functions still exist at the top of `webui/static/stats-automations.js` as dead code and can be removed in a focused cleanup pass. +- Backend routes are already grouped around `/api/import/*` and `/api/auto-import/*`. + +## Goal + +- Migrate `import` into a React-owned route without changing the user workflow. +- Preserve manual album matching, singles matching, auto-import review, and the processing queue. +- Keep staging-folder and import-processing behavior backed by the existing API routes. +- Use the completed `issues` and `stats` routes as the structural reference for route slices, API helpers, shell gating, and tests. + +## Why `import` Was The Right Next Route + +- It is the safest remaining route after excluding `help` and `hydrabase`. +- It has real workflows, so it gives the migration program more signal than another mostly-static page. +- The backend API boundary is already clearer than the broad dashboard, library, discover, sync, or settings surfaces. +- The page is important enough to validate mutation, polling, and route-local reducer patterns before larger operational pages. +- It does not need a visual redesign or a new shell abstraction to migrate cleanly. + +## Current Legacy Shape + +Page surface in `webui/index.html`: + +- Header + - import folder path + - file count and total size + - refresh action +- Processing queue + - per-job progress + - partial error display + - clear-finished action +- Tabs + - auto-import + - albums + - singles +- Auto-import tab + - enable toggle + - status text + - confidence and interval settings + - scan-now action + - live scan progress + - result filters + - approve, reject, approve-all, and clear-completed actions +- Albums tab + - auto-group suggestions from staging + - album search + - album result cards + - track matching view + - drag/drop and tap-to-assign overrides + - unmatched file pool + - process album action +- Singles tab + - staging file list + - select all / per-file selection + - per-file track search + - manual match selection + - process selected action + +Legacy JS responsibilities in `webui/static/stats-automations.js`: + +- `initializeImportPage` +- staging fetch and refresh +- tab switching +- auto-import polling +- auto-import mutations +- staging group and suggestion rendering +- album search and match POSTs +- drag/drop assignment state +- singles search and selection state +- client-side processing queue +- sequential album/singles processing requests + +Backend endpoints already available: + +- `GET /api/import/staging/files` +- `GET /api/import/staging/groups` +- `GET /api/import/staging/hints` +- `GET /api/import/staging/suggestions` +- `GET /api/import/search/albums` +- `POST /api/import/album/match` +- `POST /api/import/album/process` +- `GET /api/import/search/tracks` +- `POST /api/import/singles/process` +- `GET /api/auto-import/status` +- `POST /api/auto-import/toggle` +- `GET /api/auto-import/settings` +- `POST /api/auto-import/settings` +- `GET /api/auto-import/results` +- `POST /api/auto-import/approve/:id` +- `POST /api/auto-import/reject/:id` +- `POST /api/auto-import/scan-now` +- `POST /api/auto-import/approve-all` +- `POST /api/auto-import/clear-completed` + +## Proposed Route Slice + +```text +webui/src/routes/import/ + route.tsx + -import.types.ts + -import.api.ts + -import.helpers.ts + -route.test.tsx + -ui/ + import-page.tsx +``` + +## Proposed Route Responsibilities + +`route.tsx` + +- declare `/import` +- validate search params +- gate route through `bridge.isPageAllowed('import')` +- preload shell context +- ensure the staging-files query +- prefetch album staging groups and suggestions +- leave auto-import status/results as tab-specific client queries + +`-import.types.ts` + +- search param schema +- API response types +- staging file, staging group, album result, track result, match, auto-import result, and queue item types + +`-import.api.ts` + +- query options for staging, groups, suggestions, auto-import status, auto-import settings, auto-import results, album search, and track search +- mutation helpers for album match, album process, singles process, auto-import actions, and settings writes +- a single `invalidateImportQueries(queryClient)` helper for broad route refreshes after processing + +`-import.helpers.ts` + +- byte-size formatting +- default query param coercion +- album and track display labels +- confidence class/label mapping +- staging match normalization +- auto-import result filtering and counters + +`-ui/import-page.tsx` + +- route-local match state +- tap-selected file chip state +- singles selection and manual matches +- auto-import polling UI +- client-side processing queue state + +## Search Params + +Use URL state only for durable, shareable route state: + +- `tab` + - values: `album`, `singles`, `auto` + - default: `album` +- `autoFilter` + - values: `all`, `pending`, `imported`, `failed` + - default: `all` + +Keep these local to React state: + +- album search text +- track search text +- selected album +- match overrides +- selected singles +- processing queue jobs + +Reasoning: + +- The tab and auto-import filter are useful after reloads. +- The matching workflow is ephemeral and should not create fragile URLs with file indexes or local staging paths. + +## Query Model + +Critical route-loader data: + +- `importStagingFilesQueryOptions()` + +Useful prefetch data: + +- `importStagingGroupsQueryOptions()` +- `importStagingSuggestionsQueryOptions()` + +Tab-specific data: + +- `autoImportStatusQueryOptions()` +- `autoImportSettingsQueryOptions()` +- `autoImportResultsQueryOptions(autoFilter)` + +Lazy search data: + +- `importAlbumSearchQueryOptions(query)` +- `importTrackSearchQueryOptions(query)` + +Mutation-style actions: + +- album match draft +- process one album track +- process one single file +- toggle auto-import +- save auto-import settings +- scan now +- approve/reject auto-import result +- approve all +- clear completed + +Invalidation rules: + +- Processing album or singles files invalidates staging files, staging groups, staging suggestions, auto-import results, and any route-local queue completion summary. +- Auto-import actions invalidate auto-import status and results. +- Auto-import settings writes invalidate settings and status. +- Refresh invalidates staging files, groups, and suggestions. + +## Incremental Migration Order + +Recommended order: + +1. Add route slice, types, API helpers, reducer, and helper tests. +2. Build the React route shell with header, tabs, and staging summary. +3. Port the Albums tab search and suggestions, but keep processing disabled until match rendering is covered. +4. Port the album matching view, including drag/drop and tap assignment. +5. Port the processing queue and album/singles process mutations. +6. Port the Singles tab. +7. Port the Auto tab and polling behavior. +8. Flip `import` from `legacy` to `react` in the shell route manifest. +9. Remove the legacy `import-page` DOM from `webui/index.html`. +10. Remove import-specific legacy functions from `webui/static/stats-automations.js`. + +This order gives us a visible React page early while delaying the highest-risk file-processing actions until the state model is tested. + +## Testing Sketch + +Unit tests: + +- tab and filter search-param defaults +- staging summary formatting +- auto-import counters +- confidence labels +- reducer assignment behavior +- reducer queue transitions + +API tests: + +- staging files success and error +- staging groups success and error +- album search success and empty result +- track search success and empty result +- album match success and failure +- album process success and partial error +- singles process success and partial error +- auto-import status/results/settings/actions + +Route / component tests: + +- unauthorized users redirect to profile home +- default route renders the Albums tab +- `?tab=singles` renders the Singles tab +- `?tab=auto&autoFilter=pending` renders pending auto-import results +- refresh invalidates staging queries +- album selection opens the match view +- drag/drop and tap assignment update track matches +- processing queue advances and refreshes staging on completion + +Playwright can wait until after route ownership flips. + +## Risks + +- The processing queue is client-side and long-running. +- Auto-import polling must stop when leaving the tab or route. +- File indexes can become stale after staging refreshes. +- Album matching depends on preserving source, album name, and album artist from search results. +- The page currently shares a large legacy module with stats and automations code, so cleanup should be careful and incremental. + +## Decisions To Keep Simple + +- Keep the current visual language. +- Keep the existing backend endpoints. +- Keep the processing queue client-side for the first migration. +- Keep file matching state local to the route. +- Do not extract shared workflow primitives until a second migrated route needs them. + +## Outcome + +- The route now serves as the first React-owned workflow migration. +- The implementation uses validated search params for `tab` and `autoFilter`. +- The route uses TanStack Query for staging data, suggestions, auto-import polling, mutations, and invalidation. +- Tests cover shell ownership, URL tab state, album match payload preservation, and auto-import rendering. + +## Recommendation + +Treat remaining work as cleanup and hardening rather than route selection. + +Follow-up work should optimize for: + +- shrinking `stats-automations.js` after cutover +- adding E2E coverage around full album and singles processing +- considering route-level code splitting once more large React routes land + +It should not optimize for: + +- redesign +- backend reshaping +- shared queue abstractions +- migrating `automations` at the same time diff --git a/webui/docs/migration/page-migration-overview.md b/webui/docs/migration/page-migration-overview.md index 01c96a57..6016df4b 100644 --- a/webui/docs/migration/page-migration-overview.md +++ b/webui/docs/migration/page-migration-overview.md @@ -1,10 +1,10 @@ # WebUI Page Migration Overview -Snapshot date: 2026-05-14 +Snapshot date: 2026-05-15 ## Summary - The shell route manifest now has 18 page ids. -- `issues` and `stats` are now React-owned routes. +- `issues`, `stats`, and `import` are now React-owned routes. - Since the last snapshot, the biggest changes are: - `downloads` was renamed into `search`. - The live queue became `active-downloads`. @@ -78,9 +78,9 @@ Rollups: | --- | --- | --- | --- | --- | --- | | `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | | `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | +| `import` | React | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Completed | | `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 | | `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | -| `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 | | `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 | | `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | | `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | @@ -130,11 +130,12 @@ Rollups: - Recommendation: low-risk route with a narrow surface. #### `import` -- Current owner: Legacy. -- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`. +- Current owner: React. +- Primary files: `webui/src/routes/import/*`, `webui/src/platform/shell/route-manifest.ts`. - Main surface: staging files, album and singles matching, suggestion cards, processing queue. - Key coupling: settings-derived staging path assumptions and downstream library state. -- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `hydrabase`. +- Recommendation: completed as the next migration after `stats`. Follow-up cleanup should remove the dead legacy import functions from `webui/static/stats-automations.js`. +- Route plan: `webui/docs/migration/import-migration-plan.md`. ### Wave 2: Search split @@ -264,9 +265,10 @@ Rollups: - Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage. ## Final Recommendation -- Keep `issues` and `stats` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. +- Keep `issues`, `stats`, and `import` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. - Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history. -- Migrate the remaining safe legacy routes first: `help`, `hydrabase`, and `import`. +- Migrate the remaining safe legacy routes first: `help` and `hydrabase`. +- `import` has already been migrated and should be treated as the first React-owned workflow route. - During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real. -- Use `search` as the next meaningful proving ground now that the download queue has been split out. +- Use `search` as the next larger proving ground after `import`, now that the download queue has been split out. - Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk. diff --git a/webui/index.html b/webui/index.html index 43b1e265..709c9b9e 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6199,159 +6199,6 @@ - -
-
- -
-
-

Import Music

- -
-
- Import folder: loading... - -
-
- - - - - -
- - - -
- - -
-
-
- - Disabled - -
- - - -
- - - - -
-
-

Enable auto-import to watch your import folder for new music.

-

Drop album folders or single tracks into your import folder and SoulSync will identify, match, and import them automatically.

-
-
-
- - -
- -
-
- -
-
- -
-
- - - - -
- - -
-
-
- - -
-
-
-
Navigate to this page to scan your import folder for audio files.
-
- -
-
-
-
diff --git a/webui/package-lock.json b/webui/package-lock.json index 23b81ab1..3e6bf5a6 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -15,7 +15,8 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "recharts": "^3.8.1", - "zod": "^4.4.2" + "zod": "^4.4.2", + "zustand": "^5.0.13" }, "devDependencies": { "@playwright/test": "^1.59.1", @@ -5391,6 +5392,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/webui/package.json b/webui/package.json index 56123f51..ad4cb775 100644 --- a/webui/package.json +++ b/webui/package.json @@ -21,7 +21,8 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "recharts": "^3.8.1", - "zod": "^4.4.2" + "zod": "^4.4.2", + "zustand": "^5.0.13" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 8b043665..dbc51571 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -9,6 +9,13 @@ import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './b declare global { interface Window { showToast?: (message: string, type?: string, durationOrContext?: number | string) => void; + showConfirmDialog?: (options?: { + title?: string; + message?: string; + confirmText?: string; + cancelText?: string; + destructive?: boolean; + }) => Promise; SoulSyncIssueDomain?: IssueDomainBridge; SoulSyncWorkflowActions?: { openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise; diff --git a/webui/src/platform/shell/route-manifest.test.ts b/webui/src/platform/shell/route-manifest.test.ts index 2727c255..e4f8c978 100644 --- a/webui/src/platform/shell/route-manifest.test.ts +++ b/webui/src/platform/shell/route-manifest.test.ts @@ -31,6 +31,13 @@ describe('shellRouteManifest', () => { expect(resolveShellPageFromPath('/issues/')).toBe('issues'); }); + it('resolves nested React route paths to their shell page', () => { + expect(resolveShellPageFromPath('/import/album')).toBe('import'); + expect(resolveShellPageFromPath('/import/auto')).toBe('import'); + expect(resolveShellPageFromPath('/import/singles')).toBe('import'); + expect(resolveLegacyShellPageFromPath('/import/album')).toBeNull(); + }); + it('keeps a route entry for every manifest page id', () => { expect(shellRouteManifest).not.toHaveLength(0); expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard'); @@ -43,8 +50,9 @@ describe('shellRouteManifest', () => { it('tracks whether a route is rendered by React or the legacy shell', () => { expect(getShellRouteByPageId('issues')?.kind).toBe('react'); expect(getShellRouteByPageId('stats')?.kind).toBe('react'); + expect(getShellRouteByPageId('import')?.kind).toBe('react'); expect(getShellRouteByPageId('discover')?.kind).toBe('legacy'); - expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['stats', 'issues']); + expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['import', 'stats', 'issues']); expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true); }); diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts index bbcd7d09..c09a18b6 100644 --- a/webui/src/platform/shell/route-manifest.ts +++ b/webui/src/platform/shell/route-manifest.ts @@ -38,7 +38,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [ { pageId: 'wishlist', path: '/wishlist', kind: 'legacy' }, { pageId: 'automations', path: '/automations', kind: 'legacy' }, { pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' }, - { pageId: 'import', path: '/import', kind: 'legacy' }, + { pageId: 'import', path: '/import', kind: 'react' }, { pageId: 'library', path: '/library', kind: 'legacy' }, { pageId: 'tools', path: '/tools', kind: 'legacy' }, { pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' }, @@ -67,7 +67,11 @@ export function getShellRouteByPageId(pageId: ShellPageId): ShellRouteDefinition } export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined { - return routeByPath.get(normalizeShellPath(pathname) as `/${string}`); + const normalized = normalizeShellPath(pathname); + const exactRoute = routeByPath.get(normalized as `/${string}`); + if (exactRoute) return exactRoute; + + return reactShellRoutes.find((route) => normalized.startsWith(`${route.path}/`)); } export function resolveShellPageFromPath(pathname: string): ShellPageId | null { diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts index addc76fb..7f935df1 100644 --- a/webui/src/routeTree.gen.ts +++ b/webui/src/routeTree.gen.ts @@ -12,7 +12,12 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SplatRouteImport } from './routes/$' import { Route as StatsRouteRouteImport } from './routes/stats/route' import { Route as IssuesRouteRouteImport } from './routes/issues/route' +import { Route as ImportRouteRouteImport } from './routes/import/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as ImportIndexRouteImport } from './routes/import/index' +import { Route as ImportSinglesRouteImport } from './routes/import/singles' +import { Route as ImportAutoRouteImport } from './routes/import/auto' +import { Route as ImportAlbumRouteImport } from './routes/import/album' import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id' const SplatRoute = SplatRouteImport.update({ @@ -30,11 +35,36 @@ const IssuesRouteRoute = IssuesRouteRouteImport.update({ path: '/issues', getParentRoute: () => rootRouteImport, } as any) +const ImportRouteRoute = ImportRouteRouteImport.update({ + id: '/import', + path: '/import', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const ImportIndexRoute = ImportIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ImportRouteRoute, +} as any) +const ImportSinglesRoute = ImportSinglesRouteImport.update({ + id: '/singles', + path: '/singles', + getParentRoute: () => ImportRouteRoute, +} as any) +const ImportAutoRoute = ImportAutoRouteImport.update({ + id: '/auto', + path: '/auto', + getParentRoute: () => ImportRouteRoute, +} as any) +const ImportAlbumRoute = ImportAlbumRouteImport.update({ + id: '/album', + path: '/album', + getParentRoute: () => ImportRouteRoute, +} as any) const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({ id: '/artist-detail/$source/$id', path: '/artist-detail/$source/$id', @@ -43,9 +73,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/import': typeof ImportRouteRouteWithChildren '/issues': typeof IssuesRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute + '/import/album': typeof ImportAlbumRoute + '/import/auto': typeof ImportAutoRoute + '/import/singles': typeof ImportSinglesRoute + '/import/': typeof ImportIndexRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesByTo { @@ -53,32 +88,66 @@ export interface FileRoutesByTo { '/issues': typeof IssuesRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute + '/import/album': typeof ImportAlbumRoute + '/import/auto': typeof ImportAutoRoute + '/import/singles': typeof ImportSinglesRoute + '/import': typeof ImportIndexRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/import': typeof ImportRouteRouteWithChildren '/issues': typeof IssuesRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute + '/import/album': typeof ImportAlbumRoute + '/import/auto': typeof ImportAutoRoute + '/import/singles': typeof ImportSinglesRoute + '/import/': typeof ImportIndexRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' + fullPaths: + | '/' + | '/import' + | '/issues' + | '/stats' + | '/$' + | '/import/album' + | '/import/auto' + | '/import/singles' + | '/import/' + | '/artist-detail/$source/$id' fileRoutesByTo: FileRoutesByTo - to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' - id: - | '__root__' + to: | '/' | '/issues' | '/stats' | '/$' + | '/import/album' + | '/import/auto' + | '/import/singles' + | '/import' + | '/artist-detail/$source/$id' + id: + | '__root__' + | '/' + | '/import' + | '/issues' + | '/stats' + | '/$' + | '/import/album' + | '/import/auto' + | '/import/singles' + | '/import/' | '/artist-detail/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ImportRouteRoute: typeof ImportRouteRouteWithChildren IssuesRouteRoute: typeof IssuesRouteRoute StatsRouteRoute: typeof StatsRouteRoute SplatRoute: typeof SplatRoute @@ -108,6 +177,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IssuesRouteRouteImport parentRoute: typeof rootRouteImport } + '/import': { + id: '/import' + path: '/import' + fullPath: '/import' + preLoaderRoute: typeof ImportRouteRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -115,6 +191,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/import/': { + id: '/import/' + path: '/' + fullPath: '/import/' + preLoaderRoute: typeof ImportIndexRouteImport + parentRoute: typeof ImportRouteRoute + } + '/import/singles': { + id: '/import/singles' + path: '/singles' + fullPath: '/import/singles' + preLoaderRoute: typeof ImportSinglesRouteImport + parentRoute: typeof ImportRouteRoute + } + '/import/auto': { + id: '/import/auto' + path: '/auto' + fullPath: '/import/auto' + preLoaderRoute: typeof ImportAutoRouteImport + parentRoute: typeof ImportRouteRoute + } + '/import/album': { + id: '/import/album' + path: '/album' + fullPath: '/import/album' + preLoaderRoute: typeof ImportAlbumRouteImport + parentRoute: typeof ImportRouteRoute + } '/artist-detail/$source/$id': { id: '/artist-detail/$source/$id' path: '/artist-detail/$source/$id' @@ -125,8 +229,27 @@ declare module '@tanstack/react-router' { } } +interface ImportRouteRouteChildren { + ImportAlbumRoute: typeof ImportAlbumRoute + ImportAutoRoute: typeof ImportAutoRoute + ImportSinglesRoute: typeof ImportSinglesRoute + ImportIndexRoute: typeof ImportIndexRoute +} + +const ImportRouteRouteChildren: ImportRouteRouteChildren = { + ImportAlbumRoute: ImportAlbumRoute, + ImportAutoRoute: ImportAutoRoute, + ImportSinglesRoute: ImportSinglesRoute, + ImportIndexRoute: ImportIndexRoute, +} + +const ImportRouteRouteWithChildren = ImportRouteRoute._addFileChildren( + ImportRouteRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ImportRouteRoute: ImportRouteRouteWithChildren, IssuesRouteRoute: IssuesRouteRoute, StatsRouteRoute: StatsRouteRoute, SplatRoute: SplatRoute, diff --git a/webui/src/routes/import/-import.api.ts b/webui/src/routes/import/-import.api.ts new file mode 100644 index 00000000..624d8a36 --- /dev/null +++ b/webui/src/routes/import/-import.api.ts @@ -0,0 +1,293 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query'; + +import { apiClient, readJson } from '@/app/api-client'; + +import type { + ImportAlbum, + ImportAlbumMatch, + ImportAlbumMatchPayload, + ImportAlbumSearchPayload, + ImportAutoImportResultsPayload, + ImportAutoImportSettingsPayload, + ImportAutoImportStatusPayload, + ImportProcessPayload, + ImportStagingFilesPayload, + ImportStagingGroupsPayload, + ImportTrackSearchPayload, +} from './-import.types'; + +export const IMPORT_QUERY_KEY = ['import'] as const; + +function assertSuccess( + payload: T, + fallback: string, +): T { + if (!payload.success) { + throw new Error(payload.error || fallback); + } + return payload; +} + +export async function fetchImportStagingFiles(): Promise { + return assertSuccess( + await readJson(apiClient.get('import/staging/files')), + 'Failed to load import folder', + ); +} + +export async function fetchImportStagingGroups(): Promise { + return assertSuccess( + await readJson(apiClient.get('import/staging/groups')), + 'Failed to load auto-detected albums', + ); +} + +export async function fetchImportStagingSuggestions(): Promise { + return assertSuccess( + await readJson(apiClient.get('import/staging/suggestions')), + 'Failed to load import suggestions', + ); +} + +export async function searchImportAlbums(query: string): Promise { + return assertSuccess( + await readJson( + apiClient.get('import/search/albums', { + searchParams: { + q: query, + limit: '12', + }, + }), + ), + 'Album search failed', + ); +} + +export async function matchImportAlbum(input: { + albumId: string; + source?: string | null; + albumName?: string | null; + albumArtist?: string | null; + filePaths?: string[] | null; +}): Promise { + return assertSuccess( + await readJson( + apiClient.post('import/album/match', { + json: { + album_id: input.albumId, + source: input.source || '', + album_name: input.albumName || '', + album_artist: input.albumArtist || '', + ...(input.filePaths?.length ? { file_paths: input.filePaths } : {}), + }, + }), + ), + 'Failed to match album', + ); +} + +export async function processImportAlbumTrack(input: { + album: ImportAlbum; + match: ImportAlbumMatch; +}): Promise { + return assertSuccess( + await readJson( + apiClient.post('import/album/process', { + json: { + album: input.album, + matches: [input.match], + }, + }), + ), + 'Failed to process album track', + ); +} + +export async function searchImportTracks(query: string): Promise { + return assertSuccess( + await readJson( + apiClient.get('import/search/tracks', { + searchParams: { + q: query, + limit: '6', + }, + }), + ), + 'Track search failed', + ); +} + +export async function processImportSingleFile(file: unknown): Promise { + return assertSuccess( + await readJson( + apiClient.post('import/singles/process', { + json: { + files: [file], + }, + }), + ), + 'Failed to process single', + ); +} + +export async function fetchAutoImportStatus(): Promise { + return assertSuccess( + await readJson(apiClient.get('auto-import/status')), + 'Failed to load auto-import status', + ); +} + +export async function fetchAutoImportSettings(): Promise { + return assertSuccess( + await readJson(apiClient.get('auto-import/settings')), + 'Failed to load auto-import settings', + ); +} + +export async function saveAutoImportSettings(input: { + confidenceThreshold: number; + scanInterval: number; +}): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>( + apiClient.post('auto-import/settings', { + json: { + confidence_threshold: input.confidenceThreshold, + scan_interval: input.scanInterval, + }, + }), + ), + 'Failed to save auto-import settings', + ); +} + +export async function fetchAutoImportResults(): Promise { + return assertSuccess( + await readJson( + apiClient.get('auto-import/results', { + searchParams: { + limit: '100', + }, + }), + ), + 'Failed to load auto-import results', + ); +} + +export async function toggleAutoImport(enabled: boolean): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>( + apiClient.post('auto-import/toggle', { + json: { enabled }, + }), + ), + 'Failed to toggle auto-import', + ); +} + +export async function triggerAutoImportScan(): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>(apiClient.post('auto-import/scan-now')), + 'Failed to trigger scan', + ); +} + +export async function approveAutoImportResult(id: number): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>( + apiClient.post(`auto-import/approve/${id}`), + ), + 'Failed to approve import', + ); +} + +export async function rejectAutoImportResult(id: number): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>( + apiClient.post(`auto-import/reject/${id}`), + ), + 'Failed to dismiss import', + ); +} + +export async function approveAllAutoImportResults(): Promise { + const payload = assertSuccess( + await readJson<{ success: boolean; count?: number; error?: string }>( + apiClient.post('auto-import/approve-all'), + ), + 'Failed to approve imports', + ); + return payload.count ?? 0; +} + +export async function clearCompletedAutoImportResults(): Promise { + const payload = assertSuccess( + await readJson<{ success: boolean; count?: number; error?: string }>( + apiClient.post('auto-import/clear-completed'), + ), + 'Failed to clear import history', + ); + return payload.count ?? 0; +} + +export function importStagingFilesQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'staging-files'], + queryFn: fetchImportStagingFiles, + }); +} + +export function importStagingGroupsQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'staging-groups'], + queryFn: fetchImportStagingGroups, + }); +} + +export function importStagingSuggestionsQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'staging-suggestions'], + queryFn: fetchImportStagingSuggestions, + }); +} + +export function autoImportStatusQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'auto-import-status'], + queryFn: fetchAutoImportStatus, + }); +} + +export function autoImportSettingsQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'auto-import-settings'], + queryFn: fetchAutoImportSettings, + }); +} + +export function autoImportResultsQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'auto-import-results'], + queryFn: fetchAutoImportResults, + }); +} + +export function invalidateImportQueries(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: IMPORT_QUERY_KEY }); +} + +export function invalidateImportStagingQueries(queryClient: QueryClient) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-files'] }), + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-groups'] }), + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-suggestions'] }), + ]); +} + +export function invalidateAutoImportQueries(queryClient: QueryClient) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-status'] }), + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-settings'] }), + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-results'] }), + ]); +} diff --git a/webui/src/routes/import/-import.helpers.ts b/webui/src/routes/import/-import.helpers.ts new file mode 100644 index 00000000..0805b5d9 --- /dev/null +++ b/webui/src/routes/import/-import.helpers.ts @@ -0,0 +1,286 @@ +import type { + ImportAlbumMatch, + ImportAutoFilter, + ImportAutoImportActiveItem, + ImportAutoImportMatchData, + ImportAutoImportResult, + ImportAutoImportStatusPayload, + ImportQueueEntry, + ImportStagingFile, +} from './-import.types'; + +export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png'; + +export function formatImportBytes(bytes: number): string { + if (bytes > 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; + if (bytes > 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MB`; + return `${(bytes / 1024).toFixed(0)} KB`; +} + +export function getStagingStatsText(files: ImportStagingFile[]): string { + const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); + const fileLabel = `${files.length} file${files.length === 1 ? '' : 's'}`; + return totalSize ? `${fileLabel} - ${formatImportBytes(totalSize)}` : fileLabel; +} + +export function getTrackDisplayInfo(match: ImportAlbumMatch, index: number) { + const track = match.track || match.spotify_track || {}; + const rawTrackNumber = track.track_number ?? track.trackNumber ?? null; + const trackNumber = + rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === '' + ? null + : String(rawTrackNumber).split('/')[0]?.trim() || null; + + return { + track, + name: track.name || track.title || `Track ${index + 1}`, + trackNumber, + displayTrackNumber: trackNumber || String(index + 1), + }; +} + +export function getEffectiveAlbumMatches( + matches: ImportAlbumMatch[], + stagingFiles: ImportStagingFile[], + overrides: Record, +): ImportAlbumMatch[] { + return matches.flatMap((match, index) => { + if (Object.hasOwn(overrides, index)) { + const override = overrides[index]; + if (override === -1) return []; + const stagingFile = stagingFiles[override]; + return stagingFile ? [{ ...match, staging_file: stagingFile, confidence: 1 }] : []; + } + return match.staging_file ? [match] : []; + }); +} + +export function getDisplayedMatchFile( + match: ImportAlbumMatch, + index: number, + stagingFiles: ImportStagingFile[], + overrides: Record, +): { file: ImportStagingFile | null; confidence: number; isOverride: boolean } { + if (Object.hasOwn(overrides, index)) { + const override = overrides[index]; + if (override === -1) return { file: null, confidence: match.confidence, isOverride: false }; + return { + file: stagingFiles[override] ?? null, + confidence: 1, + isOverride: true, + }; + } + + if (!match.staging_file) { + return { file: null, confidence: match.confidence, isOverride: false }; + } + + const autoFileName = match.staging_file.filename; + const reassigned = Object.entries(overrides).some(([trackIndex, stagingFileIndex]) => { + const file = stagingFiles[stagingFileIndex]; + return file && file.filename === autoFileName && Number(trackIndex) !== index; + }); + + return { + file: reassigned ? null : match.staging_file, + confidence: match.confidence, + isOverride: false, + }; +} + +export function getUnmatchedStagingFiles( + matches: ImportAlbumMatch[], + stagingFiles: ImportStagingFile[], + overrides: Record, +): Array<{ file: ImportStagingFile; index: number }> { + return stagingFiles.flatMap((file, index) => { + if (Object.values(overrides).includes(index)) return []; + + const autoUsed = matches.some((match, matchIndex) => { + if (Object.hasOwn(overrides, matchIndex)) return false; + return match.staging_file?.filename === file.filename; + }); + + return autoUsed ? [] : [{ file, index }]; + }); +} + +export function getAutoImportCounts(results: ImportAutoImportResult[]) { + return { + imported: results.filter( + (result) => result.status === 'completed' || result.status === 'approved', + ).length, + review: results.filter((result) => result.status === 'pending_review').length, + failed: results.filter( + (result) => result.status === 'failed' || result.status === 'needs_identification', + ).length, + }; +} + +export function filterAutoImportResults( + results: ImportAutoImportResult[], + filter: ImportAutoFilter, +): ImportAutoImportResult[] { + if (filter === 'pending') return results.filter((result) => result.status === 'pending_review'); + if (filter === 'imported') { + return results.filter( + (result) => result.status === 'completed' || result.status === 'approved', + ); + } + if (filter === 'failed') { + return results.filter( + (result) => result.status === 'failed' || result.status === 'needs_identification', + ); + } + return results; +} + +export function getAutoImportStatusText(status: ImportAutoImportStatusPayload | undefined): string { + if (!status) return 'Loading...'; + if (status.paused) return 'Paused'; + if (status.current_status === 'processing') return 'Processing...'; + if (status.current_status === 'scanning') return 'Scanning...'; + if (!status.running) return 'Disabled'; + + if (status.last_scan_time) { + const lastScan = new Date(status.last_scan_time); + const diffSeconds = Math.floor((Date.now() - lastScan.getTime()) / 1000); + if (Number.isFinite(diffSeconds) && diffSeconds >= 0 && diffSeconds < 60) { + return `Watching (scanned ${diffSeconds}s ago)`; + } + if (Number.isFinite(diffSeconds) && diffSeconds < 3600) { + return `Watching (scanned ${Math.floor(diffSeconds / 60)}m ago)`; + } + } + + return 'Watching'; +} + +export function getAutoImportStatusClass( + status: ImportAutoImportStatusPayload | undefined, +): string { + if (!status?.running) return 'disabled'; + if (status.current_status === 'scanning') return 'scanning'; + if (status.current_status === 'processing') return 'processing'; + return 'active'; +} + +export function getActiveImportLines(status: ImportAutoImportStatusPayload | undefined): string[] { + const active = Array.isArray(status?.active_imports) ? status.active_imports : []; + if (active.length > 0) return active.map(getActiveImportLine); + if (status?.current_status === 'scanning') { + return [`Scanning... (${status.stats?.scanned || 0} processed)`]; + } + return []; +} + +function getActiveImportLine(item: ImportAutoImportActiveItem): string { + const folder = item.folder_name || '...'; + const trackIndex = item.track_index || 0; + const trackTotal = item.track_total || 0; + const trackName = item.track_name || ''; + if (item.status === 'processing' && trackTotal > 0) { + return `${folder} - track ${trackIndex}/${trackTotal}: ${trackName}`; + } + if (item.status === 'matching') return `${folder} - matching tracks...`; + if (item.status === 'identifying') return `${folder} - identifying...`; + return `${folder} - queued`; +} + +export function parseAutoImportMatchData( + matchData: ImportAutoImportResult['match_data'], +): ImportAutoImportMatchData { + if (!matchData) return {}; + if (typeof matchData === 'object') return matchData; + try { + const parsed = JSON.parse(matchData) as ImportAutoImportMatchData; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +export function getAutoImportTimeAgo(createdAt: string | null | undefined): string { + if (!createdAt) return ''; + const created = new Date(createdAt); + const diffMinutes = Math.floor((Date.now() - created.getTime()) / 60_000); + if (!Number.isFinite(diffMinutes) || diffMinutes < 0) return ''; + if (diffMinutes < 1) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffMinutes < 1440) return `${Math.floor(diffMinutes / 60)}h ago`; + return `${Math.floor(diffMinutes / 1440)}d ago`; +} + +export function getConfidenceClass(confidencePercent: number): 'high' | 'medium' | 'low' { + if (confidencePercent >= 90) return 'high'; + if (confidencePercent >= 70) return 'medium'; + return 'low'; +} + +export function getAutoImportStatusMeta(status: string): { + label: string; + icon: string; + className: 'completed' | 'review' | 'failed' | 'processing' | 'neutral'; +} { + const labels: Record = { + completed: 'Imported', + pending_review: 'Needs Review', + needs_identification: 'Unidentified', + failed: 'Failed', + scanning: 'Scanning...', + matched: 'Matched', + rejected: 'Dismissed', + approved: 'Approved', + processing: 'Processing', + }; + + const icons: Record = { + completed: 'OK', + pending_review: '!', + needs_identification: 'x', + failed: 'x', + scanning: '~', + matched: 'OK', + rejected: 'x', + approved: 'OK', + processing: '~', + }; + + return { + label: labels[status] || status, + icon: icons[status] || '', + className: + status === 'completed' + ? 'completed' + : status === 'pending_review' + ? 'review' + : status === 'failed' || status === 'needs_identification' + ? 'failed' + : status === 'processing' + ? 'processing' + : 'neutral', + }; +} + +export function getQueueProgressPercent(entry: ImportQueueEntry): number { + if (entry.status === 'done' || entry.status === 'error') return 100; + if (entry.total <= 0) return 0; + return Math.round((entry.processed / entry.total) * 100); +} + +export function getQueueStatusText(entry: ImportQueueEntry): string { + if (entry.status === 'running') return `${entry.processed}/${entry.total}`; + if (entry.status === 'done') { + return entry.errors.length > 0 + ? `${entry.processed}/${entry.total} (${entry.errors.length} err)` + : 'Done'; + } + return 'Failed'; +} + +export function formatDuration(durationMs: number | null | undefined): string { + if (!durationMs) return ''; + const minutes = Math.floor(durationMs / 60_000); + const seconds = String(Math.floor((durationMs % 60_000) / 1000)).padStart(2, '0'); + return `${minutes}:${seconds}`; +} diff --git a/webui/src/routes/import/-import.store.ts b/webui/src/routes/import/-import.store.ts new file mode 100644 index 00000000..88a09e2f --- /dev/null +++ b/webui/src/routes/import/-import.store.ts @@ -0,0 +1,236 @@ +import { create } from 'zustand'; +import { combine } from 'zustand/middleware'; +import { useShallow } from 'zustand/react/shallow'; + +import type { + ImportAlbumMatchPayload, + ImportAlbumResult, + ImportQueueEntry, + ImportQueueJob, + ImportTrackResult, +} from './-import.types'; + +export type SingleSearchState = { + query: string; + loading: boolean; + error: string | null; + results: ImportTrackResult[]; +}; + +type StateUpdater = T | ((current: T) => T); + +function resolveState(current: T, updater: StateUpdater) { + return typeof updater === 'function' ? (updater as (value: T) => T)(current) : updater; +} + +function createInitialWorkflowState() { + return { + queue: [] as ImportQueueEntry[], + nextQueueId: 0, + albumQuery: '', + albumResults: null as ImportAlbumResult[] | null, + albumSearchError: null as string | null, + albumSearchLoading: false, + autoGroupFilePaths: null as string[] | null, + selectedAlbum: null as ImportAlbumResult | null, + albumMatch: null as ImportAlbumMatchPayload | null, + albumMatchError: null as string | null, + albumMatchLoading: false, + matchOverrides: {} as Record, + selectedSingles: new Set(), + singlesManualMatches: {} as Record, + openSingleSearch: null as number | null, + singleSearches: {} as Record, + }; +} + +export const useImportWorkflowStore = create( + combine(createInitialWorkflowState(), (set, get) => ({ + clearFinishedJobs: () => { + set((state) => ({ queue: state.queue.filter((entry) => entry.status === 'running') })); + }, + enqueueQueueJob: (job: ImportQueueJob) => { + const id = get().nextQueueId + 1; + const entry: ImportQueueEntry = { + id, + type: job.type, + label: job.label, + sublabel: job.sublabel, + imageUrl: job.imageUrl, + status: 'running', + processed: 0, + total: job.items.length, + errors: [], + }; + + set((state) => ({ + nextQueueId: id, + queue: [...state.queue, entry], + })); + return id; + }, + updateQueueEntry: (entryId: number, patch: Partial) => { + set((state) => ({ + queue: state.queue.map((entry) => (entry.id === entryId ? { ...entry, ...patch } : entry)), + })); + }, + resetAlbumSearch: () => { + set({ + albumQuery: '', + albumResults: null, + albumSearchError: null, + albumSearchLoading: false, + autoGroupFilePaths: null, + selectedAlbum: null, + albumMatch: null, + albumMatchError: null, + albumMatchLoading: false, + matchOverrides: {}, + }); + }, + setAlbumQuery: (albumQuery: string) => set({ albumQuery }), + setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }), + setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }), + setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }), + setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => { + set({ + albumQuery, + albumSearchLoading: true, + albumSearchError: null, + albumResults: null, + selectedAlbum: null, + albumMatch: null, + autoGroupFilePaths, + }); + }, + setSelectedAlbum: (selectedAlbum: ImportAlbumResult | null) => set({ selectedAlbum }), + setAlbumMatch: (albumMatch: ImportAlbumMatchPayload | null) => set({ albumMatch }), + setAlbumMatchError: (albumMatchError: string | null) => set({ albumMatchError }), + setAlbumMatchLoading: (albumMatchLoading: boolean) => set({ albumMatchLoading }), + clearAutoGroupFilePaths: () => set({ autoGroupFilePaths: null }), + setMatchOverrides: (updater: StateUpdater>) => { + set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) })); + }, + toggleSingle: (index: number) => { + set((state) => { + const selectedSingles = new Set(state.selectedSingles); + if (selectedSingles.has(index)) selectedSingles.delete(index); + else selectedSingles.add(index); + return { selectedSingles }; + }); + }, + toggleAllSingles: (fileCount: number) => { + set((state) => ({ + selectedSingles: + state.selectedSingles.size === fileCount + ? new Set() + : new Set(Array.from({ length: fileCount }, (_, index) => index)), + })); + }, + clearSinglesSelection: () => { + set({ + selectedSingles: new Set(), + singlesManualMatches: {}, + }); + }, + setOpenSingleSearch: (openSingleSearch: number | null) => set({ openSingleSearch }), + ensureSingleSearch: (index: number, query: string) => { + set((state) => ({ + singleSearches: { + ...state.singleSearches, + [index]: state.singleSearches[index] ?? { + query, + loading: false, + error: null, + results: [], + }, + }, + })); + }, + setSingleSearch: (index: number, updater: StateUpdater) => { + set((state) => { + const current = state.singleSearches[index] ?? { + query: '', + loading: false, + error: null, + results: [], + }; + return { + singleSearches: { + ...state.singleSearches, + [index]: resolveState(current, updater), + }, + }; + }); + }, + selectSingleMatch: (fileIndex: number, track: ImportTrackResult) => { + set((state) => ({ + singlesManualMatches: { ...state.singlesManualMatches, [fileIndex]: track }, + selectedSingles: new Set(state.selectedSingles).add(fileIndex), + openSingleSearch: null, + })); + }, + })), +); + +export function resetImportWorkflowStore() { + useImportWorkflowStore.setState(createInitialWorkflowState()); +} + +export function useImportQueueWorkflow() { + return useImportWorkflowStore( + useShallow((state) => ({ + clearFinishedJobs: state.clearFinishedJobs, + enqueueQueueJob: state.enqueueQueueJob, + queue: state.queue, + updateQueueEntry: state.updateQueueEntry, + })), + ); +} + +export function useAlbumImportWorkflow() { + return useImportWorkflowStore( + useShallow((state) => ({ + albumMatch: state.albumMatch, + albumMatchError: state.albumMatchError, + albumMatchLoading: state.albumMatchLoading, + albumQuery: state.albumQuery, + albumResults: state.albumResults, + albumSearchError: state.albumSearchError, + albumSearchLoading: state.albumSearchLoading, + autoGroupFilePaths: state.autoGroupFilePaths, + clearAutoGroupFilePaths: state.clearAutoGroupFilePaths, + matchOverrides: state.matchOverrides, + resetAlbumWorkflow: state.resetAlbumSearch, + selectedAlbum: state.selectedAlbum, + setAlbumMatch: state.setAlbumMatch, + setAlbumMatchError: state.setAlbumMatchError, + setAlbumMatchLoading: state.setAlbumMatchLoading, + setAlbumQuery: state.setAlbumQuery, + setAlbumResults: state.setAlbumResults, + setAlbumSearchContext: state.setAlbumSearchContext, + setAlbumSearchError: state.setAlbumSearchError, + setAlbumSearchLoading: state.setAlbumSearchLoading, + setMatchOverrides: state.setMatchOverrides, + setSelectedAlbum: state.setSelectedAlbum, + })), + ); +} + +export function useSinglesImportWorkflow() { + return useImportWorkflowStore( + useShallow((state) => ({ + clearSinglesSelection: state.clearSinglesSelection, + ensureSingleSearch: state.ensureSingleSearch, + openSingleSearch: state.openSingleSearch, + selectedSingles: state.selectedSingles, + selectSingleMatchInStore: state.selectSingleMatch, + setOpenSingleSearch: state.setOpenSingleSearch, + setSingleSearch: state.setSingleSearch, + singleSearches: state.singleSearches, + singlesManualMatches: state.singlesManualMatches, + toggleAllSingles: state.toggleAllSingles, + toggleSingleInStore: state.toggleSingle, + })), + ); +} diff --git a/webui/src/routes/import/-import.types.ts b/webui/src/routes/import/-import.types.ts new file mode 100644 index 00000000..9767ff24 --- /dev/null +++ b/webui/src/routes/import/-import.types.ts @@ -0,0 +1,226 @@ +import { z } from 'zod'; + +export const IMPORT_AUTO_FILTER_VALUES = ['all', 'pending', 'imported', 'failed'] as const; +export type ImportAutoFilter = (typeof IMPORT_AUTO_FILTER_VALUES)[number]; + +export const importAutoSearchSchema = z.object({ + autoFilter: z.enum(IMPORT_AUTO_FILTER_VALUES).default('all').catch('all'), +}); + +export type ImportAutoSearch = z.infer; + +export interface ImportStagingFile { + filename: string; + rel_path?: string; + full_path: string; + title?: string | null; + artist?: string | null; + album?: string | null; + track_number?: string | number | null; + disc_number?: string | number | null; + extension?: string | null; + size?: number | null; + manual_match?: ImportTrackResult; +} + +export interface ImportStagingFilesPayload { + success: boolean; + files?: ImportStagingFile[]; + staging_path?: string; + error?: string; +} + +export interface ImportStagingGroup { + album: string; + artist: string; + file_count: number; + files?: Array<{ + filename: string; + full_path: string; + title?: string | null; + track_number?: string | number | null; + }>; + file_paths: string[]; +} + +export interface ImportStagingGroupsPayload { + success: boolean; + groups?: ImportStagingGroup[]; + error?: string; +} + +export interface ImportAlbumResult { + id: string; + name: string; + artist: string; + source?: string | null; + image_url?: string | null; + total_tracks?: number | null; + release_date?: string | null; +} + +export interface ImportAlbumSearchPayload { + success: boolean; + albums?: ImportAlbumResult[]; + suggestions?: ImportAlbumResult[]; + ready?: boolean; + error?: string; +} + +export interface ImportTrackResult { + id: string; + name: string; + artist: string; + album?: string | null; + source?: string | null; + image_url?: string | null; + duration_ms?: number | null; +} + +export interface ImportTrackSearchPayload { + success: boolean; + tracks?: ImportTrackResult[]; + error?: string; +} + +export interface ImportAlbum { + id?: string | number | null; + name: string; + artist: string; + source?: string | null; + image_url?: string | null; + total_tracks?: number | null; + release_date?: string | null; +} + +export interface ImportAlbumTrack { + id?: string | number | null; + name?: string | null; + title?: string | null; + track_number?: string | number | null; + trackNumber?: string | number | null; + disc_number?: number | null; +} + +export interface ImportAlbumMatch { + track?: ImportAlbumTrack | null; + spotify_track?: ImportAlbumTrack | null; + staging_file?: ImportStagingFile | null; + confidence: number; +} + +export interface ImportAlbumMatchPayload { + success: boolean; + album?: ImportAlbum; + matches?: ImportAlbumMatch[]; + error?: string; +} + +export interface ImportProcessPayload { + success: boolean; + processed?: number; + total?: number; + errors?: string[]; + error?: string; +} + +export interface ImportAutoImportActiveItem { + folder_hash?: string | null; + folder_name?: string | null; + status?: string | null; + track_index?: number | null; + track_total?: number | null; + track_name?: string | null; +} + +export interface ImportAutoImportStatusPayload { + success: boolean; + running?: boolean; + paused?: boolean; + current_status?: string | null; + last_scan_time?: string | null; + active_imports?: ImportAutoImportActiveItem[]; + stats?: { + scanned?: number; + auto_processed?: number; + pending_review?: number; + failed?: number; + }; + error?: string; +} + +export interface ImportAutoImportSettingsPayload { + success: boolean; + enabled?: boolean; + scan_interval?: number; + confidence_threshold?: number; + auto_process?: boolean; + error?: string; +} + +export interface ImportAutoImportMatchData { + matched_count?: number; + total_tracks?: number; + matches?: Array<{ + track_name?: string | null; + track?: { name?: string | null }; + file?: string | null; + confidence?: number | null; + }>; +} + +export interface ImportAutoImportResult { + id: number; + status: string; + folder_hash?: string | null; + folder_name: string; + album_name?: string | null; + artist_name?: string | null; + image_url?: string | null; + confidence?: number | null; + total_files?: number | null; + identification_method?: string | null; + match_data?: string | ImportAutoImportMatchData | null; + error_message?: string | null; + created_at?: string | null; +} + +export interface ImportAutoImportResultsPayload { + success: boolean; + results?: ImportAutoImportResult[]; + error?: string; +} + +export type ImportQueueStatus = 'running' | 'done' | 'error'; +export type ImportQueueJobType = 'album' | 'singles'; + +export interface ImportQueueEntry { + id: number; + type: ImportQueueJobType; + label: string; + sublabel: string; + imageUrl?: string | null; + status: ImportQueueStatus; + processed: number; + total: number; + errors: string[]; +} + +export interface ImportAlbumQueueJob { + type: 'album'; + label: string; + sublabel: string; + imageUrl?: string | null; + items: ImportAlbumMatch[]; + albumData: ImportAlbum; +} + +export interface ImportSinglesQueueJob { + type: 'singles'; + label: string; + sublabel: string; + imageUrl?: string | null; + items: ImportStagingFile[]; +} + +export type ImportQueueJob = ImportAlbumQueueJob | ImportSinglesQueueJob; diff --git a/webui/src/routes/import/-route.test.tsx b/webui/src/routes/import/-route.test.tsx new file mode 100644 index 00000000..2750a08b --- /dev/null +++ b/webui/src/routes/import/-route.test.tsx @@ -0,0 +1,272 @@ +import { createMemoryHistory } from '@tanstack/react-router'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; + +import { createAppQueryClient } from '@/app/query-client'; +import { AppRouterProvider, createAppRouter } from '@/app/router'; + +import { resetImportWorkflowStore } from './-import.store'; + +function createResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function createShellBridge(overrides: Partial = {}): ShellBridge { + return { + getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), + isPageAllowed: vi.fn(() => true), + getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), + resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), + setActivePageChrome: vi.fn(), + activateLegacyPath: vi.fn(), + showReactHost: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), + ...overrides, + }; +} + +function renderImportRoute(initialEntries = ['/import']) { + const queryClient = createAppQueryClient(); + const history = createMemoryHistory({ initialEntries }); + const router = createAppRouter({ history, queryClient }); + + return { + history, + router, + ...render(), + }; +} + +function getFetchUrls() { + return vi + .mocked(fetch) + .mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input))); +} + +describe('import route', () => { + let albumMatchBodies: Record[]; + + beforeEach(() => { + albumMatchBodies = []; + resetImportWorkflowStore(); + window.SoulSyncWebShellBridge = createShellBridge(); + window.showToast = vi.fn(); + window.showConfirmDialog = vi.fn(async () => true); + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes('/api/import/staging/files')) { + return createResponse({ + success: true, + staging_path: '/music/Staging', + files: [ + { + filename: '01-track.flac', + rel_path: 'Album/01-track.flac', + full_path: '/music/Staging/Album/01-track.flac', + title: 'Track One', + artist: 'Artist A', + album: 'Album A', + extension: '.flac', + }, + ], + }); + } + + if (url.includes('/api/import/staging/groups')) { + return createResponse({ + success: true, + groups: [ + { + album: 'Album A', + artist: 'Artist A', + file_count: 2, + file_paths: ['/music/Staging/Album/01-track.flac'], + }, + ], + }); + } + + if (url.includes('/api/import/staging/suggestions')) { + return createResponse({ + success: true, + ready: true, + suggestions: [ + { + id: 'album-1', + name: 'Album A', + artist: 'Artist A', + source: 'deezer', + total_tracks: 1, + release_date: '2026-01-01', + }, + ], + }); + } + + if (url.includes('/api/import/search/albums')) { + return createResponse({ + success: true, + albums: [ + { + id: 'album-1', + name: 'Album A', + artist: 'Artist A', + source: 'deezer', + total_tracks: 1, + release_date: '2026-01-01', + }, + ], + }); + } + + if (url.includes('/api/import/album/match')) { + const body = + input instanceof Request + ? ((await input.clone().json()) as Record) + : (JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as Record< + string, + unknown + >); + albumMatchBodies.push(body); + return createResponse({ + success: true, + received: body, + album: { + id: 'album-1', + name: 'Album A', + artist: 'Artist A', + source: 'deezer', + total_tracks: 1, + release_date: '2026-01-01', + }, + matches: [ + { + track: { name: 'Track One', track_number: 1 }, + staging_file: { + filename: '01-track.flac', + full_path: '/music/Staging/Album/01-track.flac', + }, + confidence: 0.95, + }, + ], + }); + } + + if (url.includes('/api/auto-import/status')) { + return createResponse({ + success: true, + running: true, + current_status: 'idle', + active_imports: [], + }); + } + + if (url.includes('/api/auto-import/settings')) { + return createResponse({ + success: true, + scan_interval: 60, + confidence_threshold: 0.9, + }); + } + + if (url.includes('/api/auto-import/results')) { + return createResponse({ + success: true, + results: [ + { + id: 4, + status: 'pending_review', + folder_hash: 'hash-1', + folder_name: 'Album A', + album_name: 'Album A', + artist_name: 'Artist A', + confidence: 0.82, + total_files: 2, + }, + ], + }); + } + + return createResponse({ success: true }); + }) as unknown as typeof fetch, + ); + }); + + it('renders the import page through the app router', async () => { + const { history } = renderImportRoute(); + + await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument()); + expect(await screen.findByText('Import Music')).toBeInTheDocument(); + expect(screen.getByText('Import: /music/Staging')).toBeInTheDocument(); + await waitFor(() => expect(history.location.pathname).toBe('/import/album')); + await waitFor(() => + expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(true), + ); + expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe( + true, + ); + expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('import'); + expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import'); + }); + + it('stores the active tab in nested route paths', async () => { + const { history } = renderImportRoute(); + + fireEvent.click(await screen.findByRole('link', { name: 'Singles' })); + + await waitFor(() => expect(history.location.pathname).toBe('/import/singles')); + expect(screen.getByText('Process Selected (0)')).toBeInTheDocument(); + }); + + it('keeps client workflow drafts across page remounts', async () => { + const view = renderImportRoute(); + + const searchInput = await screen.findByPlaceholderText('Search for an album...'); + fireEvent.change(searchInput, { target: { value: 'half matched album' } }); + view.unmount(); + + renderImportRoute(); + + expect(await screen.findByDisplayValue('half matched album')).toBeInTheDocument(); + }); + + it('preserves album source details when matching an album', async () => { + renderImportRoute(); + + const albumButtons = await screen.findAllByRole('button', { name: /Album A/ }); + fireEvent.click(albumButtons[albumButtons.length - 1]); + + await waitFor(() => expect(screen.getByText('Track Matching')).toBeInTheDocument()); + + expect(albumMatchBodies.at(-1)).toMatchObject({ + source: 'deezer', + album_name: 'Album A', + album_artist: 'Artist A', + }); + }); + + it('renders auto-import results from route search state', async () => { + renderImportRoute(['/import/auto?autoFilter=pending']); + + expect(await screen.findByText('1 review')).toBeInTheDocument(); + expect(screen.getAllByText('Album A').length).toBeGreaterThan(0); + expect(screen.getByText('Needs Review')).toBeInTheDocument(); + expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(false); + expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe( + false, + ); + }); +}); diff --git a/webui/src/routes/import/-ui/album-import-tab.tsx b/webui/src/routes/import/-ui/album-import-tab.tsx new file mode 100644 index 00000000..aa07185b --- /dev/null +++ b/webui/src/routes/import/-ui/album-import-tab.tsx @@ -0,0 +1,581 @@ +import { useQuery } from '@tanstack/react-query'; +import { type DragEvent, type KeyboardEvent, useState } from 'react'; + +import type { ImportAlbumResult } from '../-import.types'; +import styles from './import-page.module.css'; + +import { + importStagingGroupsQueryOptions, + importStagingSuggestionsQueryOptions, + matchImportAlbum, + searchImportAlbums, +} from '../-import.api'; +import { + getDisplayedMatchFile, + getEffectiveAlbumMatches, + getTrackDisplayInfo, + getUnmatchedStagingFiles, + IMPORT_PLACEHOLDER_IMAGE, +} from '../-import.helpers'; +import { useAlbumImportWorkflow } from '../-import.store'; +import { + fallbackImage, + getErrorMessage, + useImportQueueActions, + useImportStaging, +} from './import-shared'; + +function useAlbumImportViewModel() { + const { refreshStaging, stagingFiles } = useImportStaging(); + const [dragOverTrack, setDragOverTrack] = useState(null); + const [tapSelectedChip, setTapSelectedChip] = useState(null); + const groupsQuery = useQuery({ + ...importStagingGroupsQueryOptions(), + }); + const suggestionsQuery = useQuery({ + ...importStagingSuggestionsQueryOptions(), + }); + const { addQueueJob } = useImportQueueActions(); + const { + albumMatch, + albumMatchError, + albumMatchLoading, + albumQuery, + albumResults, + albumSearchError, + albumSearchLoading, + autoGroupFilePaths, + clearAutoGroupFilePaths, + matchOverrides, + resetAlbumWorkflow, + selectedAlbum, + setAlbumMatch, + setAlbumMatchError, + setAlbumMatchLoading, + setAlbumQuery, + setAlbumResults, + setAlbumSearchContext, + setAlbumSearchError, + setAlbumSearchLoading, + setMatchOverrides, + setSelectedAlbum, + } = useAlbumImportWorkflow(); + + const resetAlbumSearch = () => { + setDragOverTrack(null); + setTapSelectedChip(null); + resetAlbumWorkflow(); + refreshStaging(); + }; + + const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => { + const trimmed = query.trim(); + if (!trimmed) return; + + setAlbumSearchContext(trimmed, filePaths); + + try { + const payload = await searchImportAlbums(trimmed); + setAlbumResults(payload.albums ?? []); + } catch (error) { + setAlbumSearchError(getErrorMessage(error)); + } finally { + setAlbumSearchLoading(false); + } + }; + + const selectAlbum = async (album: ImportAlbumResult) => { + setSelectedAlbum(album); + setAlbumMatch(null); + setAlbumMatchError(null); + setAlbumMatchLoading(true); + + try { + const payload = await matchImportAlbum({ + albumId: album.id, + source: album.source, + albumName: album.name, + albumArtist: album.artist, + filePaths: autoGroupFilePaths, + }); + setAlbumMatch(payload); + setMatchOverrides({}); + setTapSelectedChip(null); + setDragOverTrack(null); + } catch (error) { + setAlbumMatchError(getErrorMessage(error)); + } finally { + clearAutoGroupFilePaths(); + setAlbumMatchLoading(false); + } + }; + + const assignMatchFile = (trackIndex: number, stagingFileIndex: number) => { + setMatchOverrides((current) => { + const next = { ...current }; + for (const [key, value] of Object.entries(next)) { + if (value === stagingFileIndex) { + delete next[Number(key)]; + } + } + next[trackIndex] = stagingFileIndex; + return next; + }); + setTapSelectedChip(null); + }; + + const unmatchTrack = (trackIndex: number) => { + setMatchOverrides((current) => { + const next = { ...current }; + delete next[trackIndex]; + if (albumMatch?.matches?.[trackIndex]?.staging_file) { + next[trackIndex] = -1; + } + return next; + }); + }; + + const processAlbum = () => { + const album = albumMatch?.album; + const matches = albumMatch?.matches ?? []; + if (!album || matches.length === 0) return; + + const effectiveMatches = getEffectiveAlbumMatches(matches, stagingFiles, matchOverrides); + if (effectiveMatches.length === 0) return; + + addQueueJob({ + type: 'album', + label: album.name, + sublabel: `${album.artist} - ${effectiveMatches.length} tracks`, + imageUrl: album.image_url, + items: effectiveMatches, + albumData: album, + }); + resetAlbumSearch(); + }; + + return { + albumMatch, + albumMatchError, + albumMatchLoading, + albumQuery, + albumResults, + albumSearchError, + albumSearchLoading, + dragOverTrack, + groups: groupsQuery.data?.groups ?? [], + matchOverrides, + onAlbumQueryChange: setAlbumQuery, + onAutoRematch: () => { + setMatchOverrides({}); + setTapSelectedChip(null); + setDragOverTrack(null); + }, + onBackToSearch: resetAlbumSearch, + onDragOverTrack: setDragOverTrack, + onProcessAlbum: processAlbum, + onRunGroupSearch: (group: { + album: string; + artist: string; + file_count: number; + file_paths: string[]; + }) => { + void runAlbumSearch(`${group.artist} ${group.album}`, group.file_paths); + }, + onRunSearch: () => { + void runAlbumSearch(albumQuery); + }, + onSelectAlbum: (album: ImportAlbumResult) => { + void selectAlbum(album); + }, + onTapAssign: assignMatchFile, + onTapSelectChip: (index: number) => { + setTapSelectedChip((current) => (current === index ? null : index)); + }, + onUnmatchTrack: unmatchTrack, + selectedAlbum, + stagingFiles, + suggestions: suggestionsQuery.data?.suggestions ?? [], + suggestionsReady: suggestionsQuery.data?.ready ?? true, + tapSelectedChip, + }; +} + +type AlbumImportViewModel = ReturnType; + +export function AlbumImportTab() { + const viewModel = useAlbumImportViewModel(); + + return ; +} + +function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewModel }) { + const { + albumMatch, + albumMatchError, + albumMatchLoading, + albumQuery, + albumResults, + albumSearchError, + albumSearchLoading, + groups, + onAlbumQueryChange, + onBackToSearch, + onRunGroupSearch, + onRunSearch, + onSelectAlbum, + selectedAlbum, + suggestions, + suggestionsReady, + } = viewModel; + + const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch; + + return ( + <> +
+ {albumResults === null && ( + <> + {groups.length > 0 && ( +
+
Auto-Detected Albums
+
+ {groups.map((group, index) => ( + + ))} +
+
+ )} + +
+
Suggested from your import folder
+
+ {suggestions.length > 0 ? ( + suggestions.map((album) => ( + + )) + ) : suggestionsReady ? null : ( +
Loading suggestions...
+ )} +
+
+ + )} + +
+ onAlbumQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onRunSearch(); + }} + /> + + +
+ +
+ {albumSearchLoading ? ( +
Searching...
+ ) : albumSearchError ? ( +
Error: {albumSearchError}
+ ) : albumResults?.length === 0 ? ( +
No albums found
+ ) : ( + albumResults?.map((album) => ( + + )) + )} +
+
+ +
+ {albumMatchLoading ? ( +
Matching files to tracklist...
+ ) : albumMatchError ? ( +
Error: {albumMatchError}
+ ) : albumMatch?.album ? ( + + ) : ( +
Select an album to start matching files.
+ )} +
+ + ); +} + +function AlbumCard({ + album, + onSelect, +}: { + album: ImportAlbumResult; + onSelect: (album: ImportAlbumResult) => void; +}) { + return ( + + ); +} + +function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) { + const { + albumMatch, + albumMatchError, + albumMatchLoading, + dragOverTrack, + matchOverrides, + onAutoRematch, + onBackToSearch, + onDragOverTrack, + onProcessAlbum, + onTapAssign, + onTapSelectChip, + onUnmatchTrack, + stagingFiles, + tapSelectedChip, + } = viewModel; + + const effectiveMatches = getEffectiveAlbumMatches( + albumMatch?.matches ?? [], + stagingFiles, + matchOverrides, + ); + const unmatchedFiles = getUnmatchedStagingFiles( + albumMatch?.matches ?? [], + stagingFiles, + matchOverrides, + ); + const matchedCount = effectiveMatches.length; + + return albumMatchLoading ? ( +
Matching files to tracklist...
+ ) : albumMatchError ? ( +
Error: {albumMatchError}
+ ) : albumMatch?.album ? ( + <> +
+ {albumMatch.album.name} +
+
{albumMatch.album.name}
+
{albumMatch.album.artist}
+
+ {albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks -{' '} + {albumMatch.album.release_date?.substring(0, 4) || ''} +
+
+
+ +
+

Track Matching

+
+ + +
+
+ +
+ {(albumMatch.matches ?? []).map((match, index) => { + const trackInfo = getTrackDisplayInfo(match, index); + const { confidence, file } = getDisplayedMatchFile( + match, + index, + stagingFiles, + matchOverrides, + ); + const confidencePercent = Math.round(confidence * 100); + return ( +
{ + if (tapSelectedChip !== null) onTapAssign(index, tapSelectedChip); + }} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + onDragOverTrack(index); + }} + onDragLeave={() => onDragOverTrack(null)} + onDrop={(event) => { + event.preventDefault(); + onDragOverTrack(null); + const stagingFileIndex = Number(event.dataTransfer.getData('text/plain')); + if (Number.isFinite(stagingFileIndex)) onTapAssign(index, stagingFileIndex); + }} + > + {trackInfo.displayTrackNumber} + {trackInfo.name} + + {file ? ( + <> + {file.filename} + = 0.7 ? '' : styles.low + }`} + > + {confidencePercent}% + + + ) : ( + Drop a file here + )} + + + {file ? ( + + ) : null} + +
+ ); + })} +
+ +
+
+ Unmatched Files ({unmatchedFiles.length}) +
+
+ {unmatchedFiles.length === 0 ? ( + All files matched + ) : ( + unmatchedFiles.map(({ file, index }) => ( + { + event.stopPropagation(); + onTapSelectChip(index); + }} + onDragStart={(event: DragEvent) => { + event.dataTransfer.setData('text/plain', String(index)); + event.dataTransfer.effectAllowed = 'move'; + }} + onKeyDown={(event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onTapSelectChip(index); + } + }} + > + {file.filename} + + )) + )} +
+
+ +
+
+ {matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched +
+ +
+ + ) : ( +
Select an album to start matching files.
+ ); +} diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx new file mode 100644 index 00000000..c8ac1075 --- /dev/null +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -0,0 +1,580 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import type { + ImportAutoFilter, + ImportAutoImportResult, + ImportAutoImportStatusPayload, +} from '../-import.types'; + +import { + approveAllAutoImportResults, + approveAutoImportResult, + autoImportResultsQueryOptions, + autoImportSettingsQueryOptions, + autoImportStatusQueryOptions, + clearCompletedAutoImportResults, + invalidateAutoImportQueries, + rejectAutoImportResult, + saveAutoImportSettings, + toggleAutoImport, + triggerAutoImportScan, +} from '../-import.api'; +import { + filterAutoImportResults, + getActiveImportLines, + getAutoImportCounts, + getAutoImportStatusClass, + getAutoImportStatusMeta, + getAutoImportStatusText, + getAutoImportTimeAgo, + getConfidenceClass, + parseAutoImportMatchData, +} from '../-import.helpers'; +import styles from './import-page.module.css'; +import { fallbackImage, getErrorMessage, RefreshIcon } from './import-shared'; + +export function AutoImportPanel({ + autoFilter, + onFilterChange, +}: { + autoFilter: ImportAutoFilter; + onFilterChange: (filter: ImportAutoFilter) => void; +}) { + const queryClient = useQueryClient(); + const [confidence, setConfidence] = useState(90); + const [interval, setInterval] = useState(60); + const [expandedRows, setExpandedRows] = useState>(() => new Set()); + + const statusQuery = useQuery({ + ...autoImportStatusQueryOptions(), + refetchInterval: 5000, + }); + const settingsQuery = useQuery({ + ...autoImportSettingsQueryOptions(), + }); + const resultsQuery = useQuery({ + ...autoImportResultsQueryOptions(), + refetchInterval: 5000, + }); + + useEffect(() => { + const settings = settingsQuery.data; + if (!settings) return; + setConfidence(Math.round((settings.confidence_threshold ?? 0.9) * 100)); + setInterval(settings.scan_interval ?? 60); + }, [settingsQuery.data]); + + const invalidateAutoImport = () => { + void invalidateAutoImportQueries(queryClient); + }; + + const toggleMutation = useMutation({ + mutationFn: toggleAutoImport, + onSuccess: (_, enabled) => { + window.showToast?.(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const saveSettingsMutation = useMutation({ + mutationFn: saveAutoImportSettings, + onSuccess: () => { + window.showToast?.('Settings saved', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const scanMutation = useMutation({ + mutationFn: triggerAutoImportScan, + onSuccess: () => { + window.showToast?.('Scan triggered', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const approveMutation = useMutation({ + mutationFn: approveAutoImportResult, + onSuccess: () => { + window.showToast?.('Approved', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const rejectMutation = useMutation({ + mutationFn: rejectAutoImportResult, + onSuccess: () => { + window.showToast?.('Dismissed', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const approveAllMutation = useMutation({ + mutationFn: async () => { + const confirmed = await confirmAction({ + title: 'Approve All', + message: 'Approve and import all pending review items?', + confirmText: 'Approve All', + }); + if (!confirmed) return null; + return await approveAllAutoImportResults(); + }, + onSuccess: (count) => { + if (count === null) return; + window.showToast?.(`Approved ${count} items`, 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const clearMutation = useMutation({ + mutationFn: clearCompletedAutoImportResults, + onSuccess: (count) => { + window.showToast?.(`Cleared ${count} imported items`, 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + + const allResults = resultsQuery.data?.results ?? []; + const results = filterAutoImportResults(allResults, autoFilter); + const counts = getAutoImportCounts(allResults); + const activeLines = getActiveImportLines(statusQuery.data); + const statusClassName = getAutoImportStatusClass(statusQuery.data); + + if (statusQuery.error) { + return ( +
+ Auto-import is unavailable: {getErrorMessage(statusQuery.error)} +
+ ); + } + + return ( + <> +
+
+ + + {getAutoImportStatusText(statusQuery.data)} + + {statusQuery.data?.running ? ( + + ) : null} +
+ {statusQuery.data?.running ? ( +
+ + + +
+ ) : null} + {activeLines.length > 0 ? ( +
+
+ {activeLines.length === 1 + ? `Processing ${activeLines[0]}` + : `Processing ${activeLines.length} imports:`} + {activeLines.length > 1 + ? activeLines.map((line) =>
{line}
) + : null} +
+
+
+
+
+ ) : null} +
+ + {allResults.length > 0 ? ( + <> +
+ + {counts.imported} imported + + + {counts.review} review + + + {counts.failed} failed + +
+
+ {(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( + + ))} +
+ {counts.review > 0 ? ( + + ) : null} + {counts.imported + counts.failed > 0 ? ( + + ) : null} +
+ + ) : null} + +
+ {resultsQuery.error ? ( +
+ Failed to load imports: {getErrorMessage(resultsQuery.error)} +
+ ) : allResults.length === 0 ? ( +
+

No imports yet. Drop album folders or single tracks into your import folder.

+
+ ) : results.length === 0 ? ( +
+

No {autoFilter === 'pending' ? 'pending review' : autoFilter} items.

+
+ ) : ( + results.map((result, index) => ( + approveMutation.mutate(result.id)} + onReject={() => rejectMutation.mutate(result.id)} + onToggle={() => { + setExpandedRows((current) => { + const next = new Set(current); + if (next.has(result.id)) next.delete(result.id); + else next.add(result.id); + return next; + }); + }} + /> + )) + )} +
+ + ); +} + +function AutoImportResultCard({ + approvePending, + expanded, + index, + rejectPending, + result, + status, + onApprove, + onReject, + onToggle, +}: { + approvePending: boolean; + expanded: boolean; + index: number; + rejectPending: boolean; + result: ImportAutoImportResult; + status: ImportAutoImportStatusPayload | undefined; + onApprove: () => void; + onReject: () => void; + onToggle: () => void; + }) { + const confidencePercent = Math.round((result.confidence || 0) * 100); + const confidenceClass = getConfidenceClass(confidencePercent); + const statusMeta = getAutoImportStatusMeta(result.status); + const liveActive = status?.active_imports?.find( + (item) => item.folder_hash === result.folder_hash, + ); + const isLiveProcessing = result.status === 'processing' && liveActive?.status === 'processing'; + const liveTrackIndex = isLiveProcessing ? liveActive?.track_index || 0 : 0; + const liveTrackTotal = isLiveProcessing ? liveActive?.track_total || 0 : 0; + const liveTrackName = isLiveProcessing ? liveActive?.track_name || '' : ''; + const matchData = parseAutoImportMatchData(result.match_data); + const trackDetails = + matchData.matches?.map((match) => ({ + name: match.track_name || match.track?.name || 'Unknown', + file: match.file ? match.file.split(/[/\\]/).pop() || '?' : '?', + confidence: Math.round((match.confidence || 0) * 100), + })) ?? []; + const matchSummary = + isLiveProcessing && liveTrackTotal > 0 + ? `track ${liveTrackIndex}/${liveTrackTotal}: ${liveTrackName}` + : matchData.total_tracks && matchData.total_tracks > 0 + ? `${matchData.matched_count || 0}/${matchData.total_tracks} tracks` + : `${result.total_files || 0} files`; + const methodLabel = getMethodLabel(result.identification_method); + const timeAgo = getAutoImportTimeAgo(result.created_at); + const statusCardClass = getAutoImportCardClass(statusMeta.className); + const statusBadgeClass = getAutoImportBadgeClass(statusMeta.className); + const confidenceFillClass = getAutoImportConfidenceClass(confidenceClass); + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggle(); + } + }} + > +
+
+ {result.image_url ? ( + + ) : ( +
Album
+ )} +
+
+
{result.album_name || result.folder_name}
+
{result.artist_name || 'Unknown Artist'}
+
+ {matchSummary} + {methodLabel ? {methodLabel} : null} + {timeAgo ? {timeAgo} : null} +
+ {result.error_message ? ( +
{result.error_message}
+ ) : null} +
+
+
+ {statusMeta.icon} {statusMeta.label} +
+
+
+
+
{confidencePercent}% confidence
+ {result.status === 'pending_review' ? ( +
+ + +
+ ) : null} +
+
+
{result.folder_name}
+ {trackDetails.length > 0 ? ( +
+
+ Track + Matched File + Conf +
+ {trackDetails.map((track, trackIndex) => { + const rowClassName = [ + styles.autoImportTrackRow, + isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 === liveTrackIndex + ? styles.autoImportTrackRowActive + : '', + isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 < liveTrackIndex + ? styles.autoImportTrackRowDone + : '', + ] + .filter(Boolean) + .join(' '); + return ( +
+ {track.name} + {track.file} + + {track.confidence}% + +
+ ); + })} +
+ ) : null} +
+ ); +} + +function showMutationError(error: unknown) { + window.showToast?.(getErrorMessage(error), 'error'); +} + +function getAutoImportCardClass(status: string): string { + const classes: Record = { + completed: styles.autoImportCompleted, + review: styles.autoImportReview, + failed: styles.autoImportFailed, + processing: styles.autoImportProcessing, + }; + + return classes[status] ?? ''; +} + +function getAutoImportBadgeClass(status: string): string { + const classes: Record = { + completed: styles.autoImportBadgeCompleted, + review: styles.autoImportBadgeReview, + failed: styles.autoImportBadgeFailed, + neutral: styles.autoImportBadgeNeutral, + processing: styles.autoImportBadgeProcessing, + }; + + return classes[status] ?? ''; +} + +function getAutoImportConfidenceClass(status: string): string { + const classes: Record = { + high: styles.autoImportConfHigh, + medium: styles.autoImportConfMedium, + low: styles.autoImportConfLow, + }; + + return classes[status] ?? ''; +} + +function getMethodLabel(method: string | null | undefined): string { + const labels: Record = { + tags: 'Tags', + folder_name: 'Folder Name', + acoustid: 'AcoustID', + filename: 'Filename', + }; + return method ? labels[method] || method : ''; +} + +function titleCase(value: string): string { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +async function confirmAction({ + title, + message, + confirmText, +}: { + title: string; + message: string; + confirmText: string; +}): Promise { + if (window.showConfirmDialog) { + return await window.showConfirmDialog({ title, message, confirmText }); + } + return window.confirm(message); +} diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css new file mode 100644 index 00000000..2f7e71b9 --- /dev/null +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -0,0 +1,1645 @@ +.importPageContainer { + max-width: 1200px; + margin: 0 auto; + padding: 24px 32px; +} + +.importPageHeader { + margin-bottom: 20px; +} + +.importPageTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.importPageTitle { + font-size: 28px; + font-weight: 700; + margin: 0; + letter-spacing: -0.3px; + font-family: + 'SF Pro Display', + -apple-system, + BlinkMacSystemFont, + sans-serif; + display: flex; + align-items: center; + gap: 14px; +} + +.importPageTitle > span { + background: linear-gradient( + 90deg, + #ffffff 0%, + rgb(var(--accent-light-rgb)) 50%, + rgb(var(--accent-rgb)) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: page-title-shimmer 6s ease-in-out infinite; +} + +.importPageRefreshBtn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #ccc; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.importPageRefreshBtn:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +.importPageStagingBar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +.importStagingPath { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.importStagingStats { + color: rgb(var(--accent-light-rgb)); + font-weight: 500; + white-space: nowrap; +} + +/* Tab Bar */ +.importPageTabBar { + display: flex; + gap: 4px; + margin-bottom: 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 0; +} + +.importPageTab { + padding: 10px 24px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + margin-bottom: -1px; +} + +.importPageTab:hover { + color: rgba(255, 255, 255, 0.8); +} + +.importPageTab.active { + color: rgb(var(--accent-light-rgb)); + border-bottom-color: rgb(var(--accent-light-rgb)); +} + +.importPageTabContent { + display: none; +} + +.importPageTabContent.active { + display: block; +} + +.hidden { + display: none; +} + +/* Section labels */ +.importPageSectionLabel { + font-size: 13px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +/* Search bar */ +.importPageSearchBar { + display: flex; + gap: 8px; + margin-bottom: 20px; + position: relative; +} + +.importPageSearchInput { + flex: 1; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.importPageSearchInput:focus { + border-color: rgba(var(--accent-light-rgb), 0.5); +} + +.importPageSearchBtn { + padding: 10px 20px; + background: rgb(var(--accent-light-rgb)); + border: none; + border-radius: 10px; + color: #000; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.importPageSearchBtn:hover { + background: #1fdf64; +} + +.importPageClearBtn { + position: absolute; + right: 100px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + font-size: 16px; + cursor: pointer; + padding: 4px 8px; +} + +.importPageClearBtn:hover { + color: #fff; +} + +/* Album grid */ +.importPageAlbumGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.importPageAlbumCard { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 12px; + cursor: pointer; + transition: all 0.2s; + text-align: center; + color: inherit; + font: inherit; +} + +.importPageAutoGroups .importPageAlbumGrid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 10px; +} + +.importPageAutoGroupCard { + display: flex; + align-items: center; + gap: 12px; + text-align: left; +} + +.importPageAutoGroupCount { + width: 48px; + height: 48px; + border-radius: 8px; + background: rgba(var(--accent-rgb, 29, 185, 84), 0.15); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 1.2em; +} + +.importPageAutoGroupInfo { + min-width: 0; +} + +.importPageAlbumCard:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(var(--accent-light-rgb), 0.3); + transform: translateY(-2px); +} + +.importPageAlbumCard img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 8px; + margin-bottom: 8px; +} + +.importPageAlbumCardTitle { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.importPageAlbumCardArtist { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageAlbumCardMeta { + font-size: 10px; + color: rgba(255, 255, 255, 0.3); + margin-top: 4px; +} + +/* Album hero (selected album) */ +.importPageAlbumHero { + display: flex; + gap: 20px; + padding: 20px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + margin-bottom: 20px; + align-items: center; +} + +.importPageAlbumHero img { + width: 120px; + height: 120px; + border-radius: 10px; + object-fit: cover; + flex-shrink: 0; +} + +.importPageAlbumHeroInfo { + flex: 1; + min-width: 0; +} + +.importPageAlbumHeroTitle { + font-size: 22px; + font-weight: 700; + color: #fff; + margin-bottom: 4px; +} + +.importPageAlbumHeroArtist { + font-size: 15px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 4px; +} + +.importPageAlbumHeroMeta { + font-size: 12px; + color: rgba(255, 255, 255, 0.35); +} + +/* Match header */ +.importPageMatchHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.importPageMatchHeader h3 { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; +} + +.importPageMatchActions { + display: flex; + gap: 8px; +} + +/* Match list */ +.importPageMatchList { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +} + +.importPageMatchRow { + display: grid; + grid-template-columns: 36px 1fr 1fr 36px; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + min-height: 44px; + transition: all 0.2s; +} + +.importPageMatchRow.dragOver { + background: rgba(var(--accent-light-rgb), 0.08); + border-color: rgba(var(--accent-light-rgb), 0.4); + box-shadow: 0 0 12px rgba(var(--accent-light-rgb), 0.15); +} + +.importPageMatchRow.matched { + border-color: rgba(var(--accent-light-rgb), 0.2); +} + +.importPageMatchNum { + font-size: 13px; + color: rgba(255, 255, 255, 0.3); + text-align: center; + font-weight: 500; +} + +.importPageMatchTrack { + font-size: 13px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageMatchFile { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.importPageMatchFile.hasFile { + color: rgb(var(--accent-light-rgb)); +} + +.importPageMatchFileName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.importPageMatchConfidence { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(var(--accent-light-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); + font-weight: 500; + white-space: nowrap; +} + +.importPageMatchConfidence.low { + background: rgba(255, 165, 0, 0.15); + color: #ffa500; +} + +.importPageMatchDropZone { + font-size: 11px; + color: rgba(255, 255, 255, 0.25); + font-style: italic; +} + +.importPageMatchUnmatch { + background: none; + border: none; + color: rgba(255, 255, 255, 0.3); + font-size: 14px; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.importPageMatchUnmatch:hover { + color: #ff4444; + background: rgba(255, 68, 68, 0.1); +} + +/* Unmatched file pool */ +.importPageUnmatchedPool { + padding: 16px; + background: rgba(255, 255, 255, 0.02); + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: 12px; + margin-bottom: 16px; +} + +.importPagePoolLabel { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 10px; + font-weight: 500; +} + +.importPagePoolChips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.importPageFileChip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 20px; + font-size: 12px; + color: #ccc; + cursor: grab; + transition: all 0.2s; + user-select: none; +} + +.importPageFileChip:active { + cursor: grabbing; +} + +.importPageFileChip:hover { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(var(--accent-light-rgb), 0.3); + color: #fff; +} + +.importPageFileChip.selected { + background: rgba(var(--accent-light-rgb), 0.15); + border-color: rgba(var(--accent-light-rgb), 0.4); + color: rgb(var(--accent-light-rgb)); +} + +.importPagePoolEmpty { + font-size: 12px; + color: rgba(255, 255, 255, 0.2); + font-style: italic; +} + +/* Match footer */ +.importPageMatchFooter { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.importPageMatchStats { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +/* Buttons */ +.importPageProcessBtn { + padding: 10px 24px; + background: rgb(var(--accent-light-rgb)); + border: none; + border-radius: 10px; + color: #000; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.importPageProcessBtn:hover { + background: #1fdf64; +} + +.importPageProcessBtn:disabled { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.3); + cursor: not-allowed; +} + +.importPageSecondaryBtn { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #ccc; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.importPageSecondaryBtn:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +.importPageBackBtn { + padding: 8px 16px; + background: none; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.importPageBackBtn:hover { + color: #fff; + border-color: rgba(255, 255, 255, 0.2); +} + +/* Singles */ +.importPageSinglesHeader { + display: flex; + align-items: center; + justify-content: flex-end; + margin-bottom: 16px; +} + +.importPageSinglesActions { + display: flex; + gap: 8px; + align-items: center; +} + +.importPageSinglesList { + display: flex; + flex-direction: column; + gap: 4px; +} + +.importPageSingleItem { + display: grid; + grid-template-columns: 32px 1fr auto; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + transition: all 0.2s; +} + +.importPageSingleItem.matched { + border-color: rgba(var(--accent-light-rgb), 0.2); +} + +.importPageSingleCheckbox { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.2); + background: transparent; + padding: 0; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} + +.importPageSingleCheckbox.checked { + background: rgb(var(--accent-light-rgb)); + border-color: rgb(var(--accent-light-rgb)); +} + +.importPageSingleCheckbox.checked::after { + content: '✓'; + color: #000; + font-size: 12px; + font-weight: 700; +} + +.importPageSingleInfo { + min-width: 0; +} + +.importPageSingleFilename { + font-size: 13px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageSingleMeta { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + margin-top: 2px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.importPageSingleMatchedInfo { + font-size: 12px; + color: rgb(var(--accent-light-rgb)); + margin-top: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +.importPageSingleMatchedChange { + background: transparent; + border: 0; + padding: 0; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + font-size: 11px; + text-decoration: underline; +} + +.importPageSingleMatchedChange:hover { + color: #fff; +} + +.importPageSingleActions { + display: flex; + gap: 6px; + align-items: center; + flex-shrink: 0; +} + +.importPageIdentifyBtn { + padding: 6px 12px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + color: #ccc; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 4px; +} + +.importPageIdentifyBtn:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +/* Inline search panel for singles */ +.importPageSingleSearchPanel { + grid-column: 1 / -1; + padding: 12px 0 4px 44px; +} + +.importPageSingleSearchBar { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.importPageSingleSearchInput { + flex: 1; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #fff; + font-size: 13px; + outline: none; +} + +.importPageSingleSearchInput:focus { + border-color: rgba(var(--accent-light-rgb), 0.4); +} + +.importPageSingleSearchGo { + padding: 8px 14px; + background: rgb(var(--accent-light-rgb)); + border: none; + border-radius: 8px; + color: #000; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.importPageSingleSearchResults { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 200px; + overflow-y: auto; +} + +.importPageSingleResultItem { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + color: inherit; + font: inherit; + text-align: left; +} + +.importPageSingleResultItem:hover { + background: rgba(var(--accent-light-rgb), 0.08); + border-color: rgba(var(--accent-light-rgb), 0.25); +} + +.importPageSingleResultImg { + width: 36px; + height: 36px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +.importPageSingleResultInfo { + flex: 1; + min-width: 0; +} + +.importPageSingleResultName { + font-size: 13px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageSingleResultDetail { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); +} + +.importPageSingleResultSelect { + padding: 4px 12px; + background: rgba(var(--accent-light-rgb), 0.15); + border: 1px solid rgba(var(--accent-light-rgb), 0.3); + border-radius: 6px; + color: rgb(var(--accent-light-rgb)); + font-size: 11px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; +} + +.importPageSingleResultSelect:hover { + background: rgba(var(--accent-light-rgb), 0.25); +} + +/* Empty state */ +.importPageEmptyState { + text-align: center; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.3); + font-size: 14px; +} + +/* Suggestions */ +.importPageSuggestions { + margin-bottom: 24px; +} + +/* Processing Queue */ +.importPageQueue { + margin-bottom: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} + +.importPageQueueHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.importPageQueueTitle { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.importPageQueueClear { + background: none; + border: none; + color: rgba(255, 255, 255, 0.3); + font-size: 12px; + cursor: pointer; + padding: 2px 8px; +} + +.importPageQueueClear:hover { + color: #fff; +} + +.importPageQueueList { + display: flex; + flex-direction: column; +} + +.importPageQueueItem { + display: grid; + grid-template-columns: 44px 1fr auto; + gap: 12px; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.importPageQueueItem:last-child { + border-bottom: none; +} + +.importPageQueueArt { + width: 44px; + height: 44px; + border-radius: 6px; + object-fit: cover; +} + +.importPageQueueArtEmpty { + background: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: rgba(255, 255, 255, 0.3); +} + +.importPageFlexSpacer { + flex: 1; +} + +.importPageQueueInfo { + min-width: 0; +} + +.importPageQueueName { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageQueueDetail { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; +} + +.importPageQueueProgress { + width: 120px; + flex-shrink: 0; +} + +.importPageQueueBar { + height: 4px; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + overflow: hidden; + margin-bottom: 4px; +} + +.importPageQueueFill { + height: 100%; + background: rgb(var(--accent-light-rgb)); + border-radius: 2px; + width: 0%; + transition: width 0.3s ease; +} + +.importPageQueueFill.error { + background: #ef4444; +} + +.importPageQueueStatus { + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + text-align: right; +} + +.importPageQueueStatus.done { + color: rgb(var(--accent-light-rgb)); +} + +.importPageQueueStatus.error { + color: #ef4444; +} + +/* ======================================== + END IMPORT PAGE + .importPageFileChip { + cursor: pointer; + } + + .importPageMatchRow { + cursor: pointer; + } +} + +/* Import Page — small screen */ +@media (max-width: 768px) { + .importPageContainer { + padding: 16px; + } + + .importPageTitle { + font-size: 22px; + } + + .importPageAlbumGrid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; + } + + .importPageAlbumHero { + flex-direction: column; + text-align: center; + } + + .importPageAlbumHero img { + width: 100px; + height: 100px; + } + + .importPageMatchRow { + grid-template-columns: 28px 1fr; + gap: 8px; + } + + .importPageMatchFile { + grid-column: 1 / -1; + padding-left: 28px; + } + + .importPageMatchUnmatch { + grid-column: 1 / -1; + justify-self: end; + } + + .importPageSinglesHeader { + flex-direction: column; + align-items: flex-start; + } + + .importPageSinglesActions { + width: 100%; + flex-wrap: wrap; + } + + .importPageSingleItem { + grid-template-columns: 28px 1fr; + } + + .importPageSingleActions { + grid-column: 1 / -1; + justify-self: end; + } + + .importPageSingleSearchPanel { + padding-left: 0; + } + + .importPageStagingBar { + flex-direction: column; + gap: 4px; + align-items: flex-start; + } +} +.autoImportControls { + padding: 12px 0 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + margin-bottom: 16px; +} + +.autoImportToggleRow { + display: flex; + align-items: center; + gap: 12px; +} + +.autoImportToggleLabel { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.7); +} + +.autoImportToggleLabel input { + display: none; +} + +.autoImportToggleSlider { + position: relative; + width: 44px; + height: 24px; + background: rgba(255, 255, 255, 0.12); + border-radius: 12px; + transition: background 0.2s ease; + display: inline-block; +} + +.autoImportToggleSlider::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s ease; +} + +.autoImportToggleLabel input:checked + .autoImportToggleSlider { + background: var(--accent-color, #6366f1); +} + +.autoImportToggleLabel input:checked + .autoImportToggleSlider::after { + transform: translateX(20px); +} + +.autoImportStatus { + font-size: 12px; + font-weight: 500; + padding: 2px 10px; + border-radius: 6px; +} +.autoImportStatus.active { + color: #4ade80; + background: rgba(74, 222, 128, 0.1); +} +.autoImportStatus.scanning { + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.1); +} +.autoImportStatus.disabled { + color: rgba(255, 255, 255, 0.3); +} + +.autoImportSettingsRow { + display: flex; + align-items: center; + gap: 16px; + margin-top: 10px; + flex-wrap: wrap; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.autoImportSettingsRow label { + display: flex; + align-items: center; + gap: 6px; +} +.autoImportSettingsRow input[type='range'] { + width: 100px; +} +.autoImportSettingsRow select { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + border-radius: 6px; + padding: 3px 8px; + font-size: 12px; +} + +.autoImportEmpty { + text-align: center; + padding: 40px 20px; + color: rgba(255, 255, 255, 0.3); + font-size: 13px; +} + +/* Result cards */ +.autoImportCard { + display: flex; + flex-direction: column; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + margin-bottom: 8px; + transition: all 0.2s; +} + +.autoImportCardTop { + display: flex; + gap: 14px; + align-items: center; +} + +.autoImportCard:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.1); +} + +.autoImportCompleted { + border-left: 3px solid #4ade80; +} +.autoImportReview { + border-left: 3px solid #fbbf24; +} +.autoImportFailed { + border-left: 3px solid #f87171; +} +.autoImportProcessing { + border-left: 3px solid #60a5fa; +} + +.autoImportCardArt { + width: 56px; + height: 56px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +} + +.autoImportCardArtFallback { + width: 56px; + height: 56px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + opacity: 0.3; + flex-shrink: 0; +} + +.autoImportCardCenter { + flex: 1; + min-width: 0; +} + +.autoImportCardAlbum { + font-size: 14px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportCardArtist { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportCardFolder { + font-size: 10px; + color: rgba(255, 255, 255, 0.25); + margin-top: 2px; +} + +.autoImportMatchInfo { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + margin-top: 2px; +} + +.autoImportCardError { + font-size: 10px; + color: #f87171; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportCardRight { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; + min-width: 80px; +} + +.autoImportConfidenceBar { + width: 60px; + height: 4px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.autoImportConfidenceFill { + height: 100%; + border-radius: 2px; +} +.autoImportConfHigh { + background: #4ade80; +} +.autoImportConfMedium { + background: #fbbf24; +} +.autoImportConfLow { + background: #f87171; +} + +.autoImportConfidenceText { + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); +} + +/* Scan Now button */ +.autoImportScanNowBtn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + padding: 4px 12px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + transition: all 0.15s; + margin-left: auto; +} + +.autoImportScanNowBtn:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +/* Live progress */ +.autoImportProgress { + margin-top: 8px; + padding: 8px 12px; + background: rgba(var(--accent-rgb), 0.04); + border: 1px solid rgba(var(--accent-rgb), 0.1); + border-radius: 8px; +} + +.autoImportProgressText { + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 4px; +} + +.autoImportProgressBar { + height: 3px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.autoImportProgressFill { + height: 100%; + background: rgba(var(--accent-rgb), 0.6); + border-radius: 2px; + width: 100%; + animation: adlPulse 1.5s ease-in-out infinite; +} + +/* Stats summary */ +.autoImportStats { + display: flex; + gap: 16px; + padding: 8px 0; + margin-bottom: 4px; +} + +.autoImportStat { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + font-weight: 500; +} + +.autoImportStatReview { + color: #fbbf24; +} +.autoImportStatFailed { + color: #f87171; +} + +/* Filter pills */ +.autoImportFilters { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 0; + margin-bottom: 8px; +} + +.autoImportFilterPill { + padding: 6px 16px; + border-radius: 8px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.45); + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.autoImportFilterPill:hover { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); +} + +.autoImportFilterPill.active { + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.15); +} + +.autoImportActionBtn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 18px; + border-radius: 10px; + border: 1px solid transparent; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + letter-spacing: 0.1px; +} + +.autoImportActionPrimary { + background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.85)); + color: #fff; + border-color: rgba(var(--accent-rgb), 0.3); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.2); +} + +.autoImportActionPrimary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.3); + filter: brightness(1.1); +} + +.autoImportActionPrimary:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.autoImportActionSecondary { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + border-color: rgba(255, 255, 255, 0.08); +} + +.autoImportActionSecondary:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-color: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); +} + +/* Batch action buttons */ +.autoImportBatchBtn { + font-size: 11px; + padding: 4px 12px; + border-radius: 6px; + border: 1px solid rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.08); + color: rgba(var(--accent-rgb), 1); + cursor: pointer; + transition: all 0.15s; +} + +.autoImportBatchBtn:hover { + background: rgba(var(--accent-rgb), 0.15); +} + +.autoImportClearBtn { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); +} + +.autoImportClearBtn:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.8); +} + +.autoImportCardMeta { + display: flex; + gap: 8px; + align-items: center; + font-size: 10px; + color: rgba(255, 255, 255, 0.3); + margin-top: 3px; +} + +.autoImportMethodBadge { + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 4px; + font-size: 9px; + color: rgba(255, 255, 255, 0.45); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.autoImportCardFolderPath { + font-size: 9px; + color: rgba(255, 255, 255, 0.15); + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.03); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Expandable track list */ +.autoImportTrackList { + display: none; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.autoImportTrackList.expanded { + display: block; +} + +.autoImportTrackListHeader { + display: grid; + grid-template-columns: 1fr 1fr 50px; + gap: 8px; + font-size: 9px; + font-weight: 600; + color: rgba(255, 255, 255, 0.3); + text-transform: uppercase; + letter-spacing: 0.5px; + padding-bottom: 4px; + margin-bottom: 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.autoImportTrackRow { + display: grid; + grid-template-columns: 1fr 1fr 50px; + gap: 8px; + padding: 3px 0; + font-size: 11px; +} + +.autoImportTrackName { + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportTrackFile { + color: rgba(255, 255, 255, 0.3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportTrackConf { + text-align: right; + font-weight: 600; + font-size: 10px; +} + +.autoImportTrackConf.autoImportConfHigh { + color: #4ade80; +} +.autoImportTrackConf.autoImportConfMedium { + color: #fbbf24; +} +.autoImportTrackConf.autoImportConfLow { + color: #f87171; +} + +.autoImportStatusBadge { + font-size: 9px; + font-weight: 600; + padding: 2px 8px; + border-radius: 6px; + white-space: nowrap; +} +.autoImportBadgeCompleted { + background: rgba(74, 222, 128, 0.1); + color: #4ade80; +} +.autoImportBadgeReview { + background: rgba(251, 191, 36, 0.1); + color: #fbbf24; +} +.autoImportBadgeFailed { + background: rgba(248, 113, 113, 0.1); + color: #f87171; +} +.autoImportBadgeNeutral { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.4); +} +.autoImportBadgeProcessing { + background: rgba(96, 165, 250, 0.12); + color: #60a5fa; + animation: auto-import-badge-pulse 1.6s ease-in-out infinite; +} +@keyframes auto-import-badge-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } +} + +.autoImportTrackRowActive { + background: rgba(96, 165, 250, 0.08); + border-left: 2px solid #60a5fa; + padding-left: 6px; + margin-left: -8px; + border-radius: 3px; +} +.autoImportTrackRowActive .autoImportTrackName { + color: #60a5fa; + font-weight: 600; +} +.autoImportTrackRowDone .autoImportTrackName, +.autoImportTrackRowDone .autoImportTrackFile { + opacity: 0.4; +} + +.autoImportActions { + display: flex; + gap: 4px; + margin-top: 4px; +} + +.autoImportActions button { + font-size: 10px; + padding: 3px 10px; +} + +@media (max-width: 768px) { + .autoImportCard { + flex-direction: column; + align-items: flex-start; + } + .autoImportCardRight { + flex-direction: row; + width: 100%; + justify-content: space-between; + } +} diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx new file mode 100644 index 00000000..b92bb8a8 --- /dev/null +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -0,0 +1,184 @@ +import { Link, Outlet } from '@tanstack/react-router'; + +import { useReactPageShell } from '@/platform/shell/route-controllers'; + +import type { ImportQueueEntry } from '../-import.types'; +import styles from './import-page.module.css'; + +import { + getQueueProgressPercent, + getQueueStatusText, + getStagingStatsText, +} from '../-import.helpers'; +import { useImportQueueWorkflow } from '../-import.store'; +import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared'; + +export function ImportPage() { + useReactPageShell('import'); + + const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging(); + + return ( +
+
+ + + +
+ +
+
+
+ ); +} + +function ImportHeader({ + error, + fileCountText, + loading, + stagingPath, + onRefresh, +}: { + error: unknown; + fileCountText: string; + loading: boolean; + stagingPath: string; + onRefresh: () => void; +}) { + return ( +
+
+

+ + Import Music +

+ +
+
+ + {error ? 'Import folder: error' : `Import: ${stagingPath}`} + + + {loading ? 'loading...' : fileCountText} + +
+
+ ); +} + +function ImportProcessingQueue() { + const { clearFinishedJobs, queue } = useImportQueueWorkflow(); + const hasFinished = queue.some((entry) => entry.status !== 'running'); + + return ( +
+
+ Processing + +
+
+ {queue.map((entry) => ( + + ))} +
+
+ ); +} + +function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) { + const statusText = getQueueStatusText(entry); + const statusClass = + entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0) + ? styles.error + : entry.status === 'done' + ? styles.done + : ''; + + return ( +
+ {entry.imageUrl ? ( + + ) : ( +
+ A +
+ )} +
+
{entry.label}
+
{entry.sublabel}
+
+
+
+
+
+
{statusText}
+
+
+ ); +} + +function ImportTabNav() { + return ( + + ); +} diff --git a/webui/src/routes/import/-ui/import-shared.tsx b/webui/src/routes/import/-ui/import-shared.tsx new file mode 100644 index 00000000..b025f2a4 --- /dev/null +++ b/webui/src/routes/import/-ui/import-shared.tsx @@ -0,0 +1,106 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { ImportQueueJob } from '../-import.types'; + +import { + importStagingFilesQueryOptions, + invalidateImportStagingQueries, + processImportAlbumTrack, + processImportSingleFile, +} from '../-import.api'; +import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers'; +import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store'; + +export function useImportStaging() { + const queryClient = useQueryClient(); + const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs); + const stagingQuery = useQuery({ + ...importStagingFilesQueryOptions(), + }); + + return { + refreshStaging: () => { + clearFinishedJobs(); + void invalidateImportStagingQueries(queryClient); + }, + stagingFiles: stagingQuery.data?.files ?? [], + stagingPath: stagingQuery.data?.staging_path || 'Not configured', + stagingQuery, + }; +} + +export function useImportQueueActions() { + const queryClient = useQueryClient(); + const { enqueueQueueJob, updateQueueEntry } = useImportQueueWorkflow(); + + const runQueueJob = async (entryId: number, job: ImportQueueJob) => { + let processed = 0; + const errors: string[] = []; + + for (let index = 0; index < job.items.length; index += 1) { + const itemName = + job.type === 'album' + ? getTrackDisplayInfo(job.items[index], index).name + : job.items[index].title || job.items[index].filename || `File ${index + 1}`; + + updateQueueEntry(entryId, { + sublabel: `Processing ${index + 1}/${job.items.length}: ${itemName}`, + processed, + errors: [...errors], + }); + + try { + const payload = + job.type === 'album' + ? await processImportAlbumTrack({ + album: job.albumData, + match: job.items[index], + }) + : await processImportSingleFile(job.items[index]); + + processed += payload.processed || 0; + if (payload.errors?.length) { + errors.push(...payload.errors); + } + } catch (error) { + errors.push(`${itemName}: ${getErrorMessage(error)}`); + } + + updateQueueEntry(entryId, { + processed, + errors: [...errors], + }); + } + + updateQueueEntry(entryId, { + status: errors.length > 0 && processed === 0 ? 'error' : 'done', + processed, + errors, + }); + void invalidateImportStagingQueries(queryClient); + }; + + return { + addQueueJob: (job: ImportQueueJob) => { + const id = enqueueQueueJob(job); + void runQueueJob(id, job); + }, + }; +} + +export function RefreshIcon() { + return ( + + ); +} + +export function fallbackImage(event: { currentTarget: HTMLImageElement }) { + if (event.currentTarget.src.endsWith(IMPORT_PLACEHOLDER_IMAGE)) return; + event.currentTarget.src = IMPORT_PLACEHOLDER_IMAGE; +} + +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx new file mode 100644 index 00000000..2c3f5a53 --- /dev/null +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -0,0 +1,330 @@ +import type { SingleSearchState } from '../-import.store'; +import type { ImportTrackResult } from '../-import.types'; +import type { ImportStagingFile } from '../-import.types'; + +import styles from './import-page.module.css'; +import { searchImportTracks } from '../-import.api'; +import { formatDuration } from '../-import.helpers'; +import { useSinglesImportWorkflow } from '../-import.store'; +import { + fallbackImage, + getErrorMessage, + useImportQueueActions, + useImportStaging, +} from './import-shared'; + +export function SinglesImportTab() { + const { refreshStaging, stagingFiles } = useImportStaging(); + const { addQueueJob } = useImportQueueActions(); + const { + clearSinglesSelection, + ensureSingleSearch, + openSingleSearch, + selectedSingles, + selectSingleMatchInStore, + setOpenSingleSearch, + setSingleSearch, + singleSearches, + singlesManualMatches, + toggleAllSingles, + toggleSingleInStore, + } = useSinglesImportWorkflow(); + + const openSingleSearchPanel = (index: number) => { + if (openSingleSearch === index) { + setOpenSingleSearch(null); + return; + } + + setOpenSingleSearch(index); + const file = stagingFiles[index]; + const defaultQuery = + [file?.artist, file?.title].filter(Boolean).join(' ') || + (file?.filename || '').replace(/\.[^.]+$/, ''); + ensureSingleSearch(index, defaultQuery); + if (defaultQuery && !singleSearches[index]?.results.length) { + void runSingleSearch(index, defaultQuery); + } + }; + + const runSingleSearch = async (index: number, query: string) => { + const trimmed = query.trim(); + if (!trimmed) return; + + setSingleSearch(index, (current) => ({ + query: trimmed, + loading: true, + error: null, + results: current.results, + })); + + try { + const payload = await searchImportTracks(trimmed); + setSingleSearch(index, { + query: trimmed, + loading: false, + error: null, + results: payload.tracks ?? [], + }); + } catch (error) { + setSingleSearch(index, { + query: trimmed, + loading: false, + error: getErrorMessage(error), + results: [], + }); + } + }; + + const selectSingleMatch = (fileIndex: number, track: ImportTrackResult) => { + selectSingleMatchInStore(fileIndex, track); + }; + + const processSingles = () => { + if (selectedSingles.size === 0) return; + const filesToProcess = Array.from(selectedSingles).flatMap((index) => { + const file = stagingFiles[index]; + if (!file) return []; + const manualMatch = singlesManualMatches[index]; + return manualMatch ? [{ ...file, manual_match: manualMatch }] : [file]; + }); + + if (filesToProcess.length === 0) return; + + addQueueJob({ + type: 'singles', + label: `${filesToProcess.length} Single${filesToProcess.length === 1 ? '' : 's'}`, + sublabel: + filesToProcess + .map((file) => file.title || file.filename) + .slice(0, 3) + .join(', ') + (filesToProcess.length > 3 ? '...' : ''), + imageUrl: null, + items: filesToProcess, + }); + + clearSinglesSelection(); + refreshStaging(); + }; + + return ( + { + setSingleSearch(index, (current) => ({ + query, + loading: current.loading, + error: current.error, + results: current.results, + })); + }} + onSelectAll={() => toggleAllSingles(stagingFiles.length)} + onSelectMatch={selectSingleMatch} + onToggleSingle={toggleSingleInStore} + /> + ); +} + +export function SinglesImportPanel({ + files, + manualMatches, + openSearchIndex, + searchStates, + selected, + onOpenSearch, + onProcessSingles, + onRunSearch, + onSearchQueryChange, + onSelectAll, + onSelectMatch, + onToggleSingle, +}: { + files: ImportStagingFile[]; + manualMatches: Record; + openSearchIndex: number | null; + searchStates: Record; + selected: Set; + onOpenSearch: (index: number) => void; + onProcessSingles: () => void; + onRunSearch: (index: number, query: string) => void; + onSearchQueryChange: (index: number, query: string) => void; + onSelectAll: () => void; + onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void; + onToggleSingle: (index: number) => void; +}) { + const allSelected = files.length > 0 && selected.size === files.length; + + return ( + <> +
+
+ + +
+
+
+ {files.length === 0 ? ( +
No audio files found in import folder
+ ) : ( + files.map((file, index) => { + const manualMatch = manualMatches[index]; + const isSelected = selected.has(index); + const searchState = searchStates[index]; + return ( +
+ +
+ ) : null} +
+
+ +
+ {openSearchIndex === index ? ( + + ) : null} +
+ ); + }) + )} +
+ + ); +} + +function SingleSearchPanel({ + fileIndex, + searchState, + onQueryChange, + onRunSearch, + onSelectMatch, +}: { + fileIndex: number; + searchState: SingleSearchState | undefined; + onQueryChange: (index: number, query: string) => void; + onRunSearch: (index: number, query: string) => void; + onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void; +}) { + const query = searchState?.query ?? ''; + + return ( +
+
+ onQueryChange(fileIndex, event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onRunSearch(fileIndex, query); + }} + /> + +
+
+ {searchState?.loading ? ( +
Searching...
+ ) : searchState?.error ? ( +
Error: {searchState.error}
+ ) : searchState?.results.length === 0 ? ( +
No results found
+ ) : ( + searchState?.results.map((track, index) => ( + + )) + )} +
+
+ ); +} diff --git a/webui/src/routes/import/album.tsx b/webui/src/routes/import/album.tsx new file mode 100644 index 00000000..6c4a598a --- /dev/null +++ b/webui/src/routes/import/album.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { + importStagingGroupsQueryOptions, + importStagingSuggestionsQueryOptions, +} from './-import.api'; +import { AlbumImportTab } from './-ui/album-import-tab'; + +export const Route = createFileRoute('/import/album')({ + loader: async ({ context }) => { + void context.queryClient.prefetchQuery(importStagingGroupsQueryOptions()); + void context.queryClient.prefetchQuery(importStagingSuggestionsQueryOptions()); + }, + component: AlbumImportTab, +}); diff --git a/webui/src/routes/import/auto.tsx b/webui/src/routes/import/auto.tsx new file mode 100644 index 00000000..f2b3a1c6 --- /dev/null +++ b/webui/src/routes/import/auto.tsx @@ -0,0 +1,27 @@ +import { useNavigate } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; + +import type { ImportAutoFilter } from './-import.types'; + +import { importAutoSearchSchema } from './-import.types'; +import { AutoImportPanel } from './-ui/auto-import-tab'; + +export const Route = createFileRoute('/import/auto')({ + validateSearch: importAutoSearchSchema, + component: AutoImportRoute, +}); + +function AutoImportRoute() { + const navigate = useNavigate({ from: Route.fullPath }); + const { autoFilter } = Route.useSearch(); + + const setAutoFilter = (nextFilter: ImportAutoFilter) => { + void navigate({ + to: Route.fullPath, + search: (prev) => ({ ...prev, autoFilter: nextFilter }), + replace: true, + }); + }; + + return ; +} diff --git a/webui/src/routes/import/index.tsx b/webui/src/routes/import/index.tsx new file mode 100644 index 00000000..72f25616 --- /dev/null +++ b/webui/src/routes/import/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +export const Route = createFileRoute('/import/')({ + beforeLoad: () => { + throw redirect({ to: '/import/album', replace: true }); + }, +}); diff --git a/webui/src/routes/import/route.tsx b/webui/src/routes/import/route.tsx new file mode 100644 index 00000000..5cb5e60d --- /dev/null +++ b/webui/src/routes/import/route.tsx @@ -0,0 +1,20 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +import { getProfileHomePath } from '@/platform/shell/bridge'; + +import { importStagingFilesQueryOptions } from './-import.api'; +import { ImportPage } from './-ui/import-page'; + +export const Route = createFileRoute('/import')({ + beforeLoad: ({ context }) => { + const { bridge } = context.shell; + + if (!bridge.isPageAllowed('import')) { + throw redirect({ href: getProfileHomePath(bridge), replace: true }); + } + }, + loader: async ({ context }) => { + await context.queryClient.ensureQueryData(importStagingFilesQueryOptions()); + }, + component: ImportPage, +}); diff --git a/webui/src/routes/import/singles.tsx b/webui/src/routes/import/singles.tsx new file mode 100644 index 00000000..89a4621b --- /dev/null +++ b/webui/src/routes/import/singles.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { SinglesImportTab } from './-ui/singles-import-tab'; + +export const Route = createFileRoute('/import/singles')({ + component: SinglesImportTab, +}); From 7853d0cfb86baab032010af4416dfcdea384e415 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 24 May 2026 11:42:21 +0300 Subject: [PATCH 04/32] chore(webui): remove legacy import feature js / css code --- .../routes/import/-ui/import-page.module.css | 17 +- webui/static/init.js | 3 - webui/static/mobile.css | 77 - webui/static/stats-automations.js | 1390 +-------------- webui/static/style.css | 1508 +---------------- 5 files changed, 32 insertions(+), 2963 deletions(-) diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 2f7e71b9..db15a907 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -969,19 +969,12 @@ color: #ef4444; } -/* ======================================== - END IMPORT PAGE - .importPageFileChip { - cursor: pointer; - } - - .importPageMatchRow { - cursor: pointer; - } -} - -/* Import Page — small screen */ @media (max-width: 768px) { + .importPageFileChip, + .importPageMatchRow { + cursor: pointer; + } + .importPageContainer { padding: 16px; } diff --git a/webui/static/init.js b/webui/static/init.js index 6b760b0c..10a047b4 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2420,9 +2420,6 @@ async function loadPageData(pageId) { loadApiKeys(); loadBlacklistCount(); break; - case 'import': - initializeImportPage(); - break; case 'hydrabase': // Check connection status and pre-fill saved credentials try { diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 043ea773..bd81ae64 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -2312,86 +2312,9 @@ opacity: 0.7; transition: opacity 0.1s ease; } - - /* Import Page - Touch fallback */ - .import-page-file-chip { - cursor: pointer; - } - - .import-page-match-row { - cursor: pointer; - } } -/* Import Page — small screen */ @media (max-width: 768px) { - .import-page-container { - padding: 16px; - } - - .import-page-title { - font-size: 22px; - } - - .import-page-album-grid { - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 10px; - } - - .import-page-album-hero { - flex-direction: column; - text-align: center; - } - - .import-page-album-hero img { - width: 100px; - height: 100px; - } - - .import-page-match-row { - grid-template-columns: 28px 1fr; - gap: 8px; - } - - .import-page-match-file { - grid-column: 1 / -1; - padding-left: 28px; - } - - .import-page-match-unmatch { - grid-column: 1 / -1; - justify-self: end; - } - - .import-page-singles-header { - flex-direction: column; - align-items: flex-start; - } - - .import-page-singles-actions { - width: 100%; - flex-wrap: wrap; - } - - .import-page-single-item { - grid-template-columns: 28px 1fr; - } - - .import-page-single-actions { - grid-column: 1 / -1; - justify-self: end; - } - - .import-page-single-search-panel { - padding-left: 0; - } - - .import-page-staging-bar { - flex-direction: column; - gap: 4px; - align-items: flex-start; - } - /* Profile Picker - Mobile */ .profile-picker-grid { gap: 20px; diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 68772b35..9723c549 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1,1377 +1,3 @@ -// IMPORT PAGE (full page, replaces old modal) -// =================================================================== - -let importJobIdCounter = 0; - -const importPageState = { - stagingFiles: [], - selectedSingles: new Set(), - albumData: null, // response from /api/import/album/match - matchOverrides: {}, // { trackIndex: stagingFileIndex } — manual drag-drop overrides - singlesManualMatches: {}, // { stagingFileIndex: { id, name, artist, album, ... } } - initialized: false, - activeTab: 'album', - tapSelectedChip: null, // for mobile tap-to-assign fallback - // Album lookup cache for click handlers. Populated by suggestions / - // search renderers; read by importPageSelectAlbum so the match POST - // can include `source` + `name` + `artist` (without these, backend - // can't look up cross-source IDs and falls back to a broken - // "Unknown Artist / album_id as title / 0 tracks" placeholder — - // github issue #524). - _albumLookup: {}, // { albumId: { id, name, artist, source } } -}; - -// --- Initialization --- - -function initializeImportPage() { - if (!importPageState.initialized) { - importPageState.initialized = true; - importPageRefreshStaging(); - importPageLoadAutoGroups(); - importPageLoadSuggestions(); - } -} - -async function importPageRefreshStaging() { - // Clear finished jobs from the queue - importPageClearFinishedJobs(); - - try { - const resp = await fetch('/api/import/staging/files'); - const data = await resp.json(); - if (!data.success) { - document.getElementById('import-page-staging-path').textContent = `Import folder: error`; - return; - } - - importPageState.stagingFiles = data.files || []; - document.getElementById('import-page-staging-path').textContent = `Import: ${data.staging_path || 'Not configured'}`; - - const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0); - const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB` - : totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB` - : `${(totalSize / 1024).toFixed(0)} KB`; - document.getElementById('import-page-staging-stats').textContent = - `${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`; - - // Refresh the current tab view after data is loaded - if (importPageState.activeTab === 'singles') { - importPageRenderSinglesList(); - } else if (importPageState.activeTab === 'album') { - importPageLoadAutoGroups(); - } - // Always refresh suggestions and groups in background - importPageLoadSuggestions(); - } catch (err) { - console.error('Failed to refresh staging:', err); - } -} - -function importPageSwitchTab(tab) { - importPageState.activeTab = tab; - document.getElementById('import-page-tab-album').classList.toggle('active', tab === 'album'); - document.getElementById('import-page-tab-singles').classList.toggle('active', tab === 'singles'); - document.getElementById('import-page-tab-auto')?.classList.toggle('active', tab === 'auto'); - document.getElementById('import-page-album-content').classList.toggle('active', tab === 'album'); - document.getElementById('import-page-singles-content')?.classList.toggle('active', tab === 'singles'); - document.getElementById('import-page-auto-content')?.classList.toggle('active', tab === 'auto'); - - if (tab === 'singles' && importPageState.stagingFiles.length > 0) { - importPageRenderSinglesList(); - } - if (tab === 'auto') { - _autoImportLoadStatus(); - _autoImportLoadResults(); - _autoImportStartPolling(); - } else { - _autoImportStopPolling(); - } -} - -// ── Auto-Import Tab ── -let _autoImportPollInterval = null; -let _autoImportFilter = 'all'; -let _autoImportLastStatus = null; - -function _autoImportStartPolling() { - _autoImportStopPolling(); - _autoImportPollInterval = setInterval(async () => { - if (importPageState.activeTab === 'auto') { - await _autoImportLoadStatus(); - _autoImportLoadResults(); - } - }, 5000); -} - -function _autoImportStopPolling() { - if (_autoImportPollInterval) { clearInterval(_autoImportPollInterval); _autoImportPollInterval = null; } -} - -async function _autoImportToggle(enabled) { - // Optimistically update toggle state so it doesn't flicker - const toggle = document.getElementById('auto-import-enabled'); - if (toggle) toggle.checked = enabled; - const statusText = document.getElementById('auto-import-status-text'); - if (statusText) statusText.textContent = enabled ? 'Starting...' : 'Stopping...'; - - try { - const res = await fetch('/api/auto-import/toggle', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled }) - }); - const data = await res.json(); - if (data.success) { - showToast(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success'); - _autoImportLoadStatus(); - } else { - // Revert on failure - if (toggle) toggle.checked = !enabled; - } - } catch (e) { - showToast('Error: ' + e.message, 'error'); - if (toggle) toggle.checked = !enabled; - } -} - -async function _autoImportLoadStatus() { - try { - const res = await fetch('/api/auto-import/status'); - const data = await res.json(); - if (!data.success) return; - _autoImportLastStatus = data; - - const toggle = document.getElementById('auto-import-enabled'); - const statusText = document.getElementById('auto-import-status-text'); - const settingsRow = document.getElementById('auto-import-settings-row'); - const scanNowBtn = document.getElementById('auto-import-scan-now'); - const progressEl = document.getElementById('auto-import-progress'); - const progressText = document.getElementById('auto-import-progress-text'); - - if (toggle) toggle.checked = data.running; - if (settingsRow) settingsRow.style.display = data.running ? '' : 'none'; - if (scanNowBtn) scanNowBtn.style.display = data.running ? '' : 'none'; - - // Live scan + per-track processing progress. - // `active_imports` (added when the worker switched to a bounded - // executor pool) is the source of truth; multiple albums can be - // in flight at once. Render each one on its own line; fall back - // to the legacy single-line summary for older backend payloads. - if (progressEl) { - const active = Array.isArray(data.active_imports) ? data.active_imports : []; - if (active.length > 0) { - progressEl.style.display = ''; - if (progressText) { - const lines = active.map(a => { - const folder = a.folder_name || '...'; - const idx = a.track_index || 0; - const total = a.track_total || 0; - const trackName = a.track_name || ''; - if (a.status === 'processing' && total > 0) { - return `${folder} — track ${idx}/${total}: ${trackName}`; - } - if (a.status === 'matching') return `${folder} — matching tracks…`; - if (a.status === 'identifying') return `${folder} — identifying…`; - return `${folder} — queued`; - }); - progressText.textContent = lines.length === 1 - ? `Processing ${lines[0]}` - : `Processing ${lines.length} imports:\n${lines.join('\n')}`; - } - } else if (data.current_status === 'scanning') { - progressEl.style.display = ''; - if (progressText) { - const stats = data.stats || {}; - progressText.textContent = `Scanning… (${stats.scanned || 0} processed)`; - } - } else { - progressEl.style.display = 'none'; - } - } - - if (statusText) { - if (data.paused) statusText.textContent = 'Paused'; - else if (data.current_status === 'processing') statusText.textContent = 'Processing...'; - else if (data.current_status === 'scanning') statusText.textContent = 'Scanning...'; - else if (data.running) { - // Show last scan time - let watchText = 'Watching'; - if (data.last_scan_time) { - try { - const lastScan = new Date(data.last_scan_time); - const diffS = Math.floor((Date.now() - lastScan) / 1000); - if (diffS < 60) watchText = `Watching (scanned ${diffS}s ago)`; - else if (diffS < 3600) watchText = `Watching (scanned ${Math.floor(diffS / 60)}m ago)`; - } catch (e) {} - } - statusText.textContent = watchText; - } else statusText.textContent = 'Disabled'; - const _runningClass = data.current_status === 'scanning' - ? 'scanning' - : data.current_status === 'processing' - ? 'processing' - : 'active'; - statusText.className = 'auto-import-status ' + (data.running ? _runningClass : 'disabled'); - } - } catch (e) {} -} - -async function _autoImportLoadResults() { - const container = document.getElementById('auto-import-results'); - if (!container) return; - try { - const res = await fetch('/api/auto-import/results?limit=100'); - const data = await res.json(); - if (!data.success || !data.results || data.results.length === 0) { - if (!container.querySelector('.auto-import-card')) { - container.innerHTML = `
-

No imports yet. Drop album folders or single tracks into your import folder.

-
`; - } - // Hide stats and filters - const statsEl = document.getElementById('auto-import-stats'); - const filtersEl = document.getElementById('auto-import-filters'); - if (statsEl) statsEl.style.display = 'none'; - if (filtersEl) filtersEl.style.display = 'none'; - return; - } - - // Compute stats - const allResults = data.results; - const importedCount = allResults.filter(r => r.status === 'completed' || r.status === 'approved').length; - const reviewCount = allResults.filter(r => r.status === 'pending_review').length; - const failedCount = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification').length; - - // Update stats - const statsEl = document.getElementById('auto-import-stats'); - if (statsEl) { - statsEl.style.display = ''; - document.getElementById('auto-import-stat-imported').textContent = `${importedCount} imported`; - document.getElementById('auto-import-stat-review').textContent = `${reviewCount} review`; - document.getElementById('auto-import-stat-failed').textContent = `${failedCount} failed`; - } - - // Show filters - const filtersEl = document.getElementById('auto-import-filters'); - if (filtersEl) { - filtersEl.style.display = ''; - // Show batch action buttons when applicable - const approveAllBtn = document.getElementById('auto-import-approve-all'); - const clearBtn = document.getElementById('auto-import-clear-completed'); - if (approveAllBtn) approveAllBtn.style.display = reviewCount > 0 ? '' : 'none'; - if (clearBtn) clearBtn.style.display = (importedCount + failedCount) > 0 ? '' : 'none'; - } - - // Apply filter - let filtered = allResults; - if (_autoImportFilter === 'pending') filtered = allResults.filter(r => r.status === 'pending_review'); - else if (_autoImportFilter === 'imported') filtered = allResults.filter(r => r.status === 'completed' || r.status === 'approved'); - else if (_autoImportFilter === 'failed') filtered = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification'); - - if (filtered.length === 0) { - const filterName = _autoImportFilter === 'pending' ? 'pending review' : _autoImportFilter; - container.innerHTML = `

No ${filterName} items.

`; - return; - } - - container.innerHTML = filtered.map((r, idx) => { - const confPct = Math.round((r.confidence || 0) * 100); - const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; - const statusLabels = { - 'completed': 'Imported', 'pending_review': 'Needs Review', - 'needs_identification': 'Unidentified', 'failed': 'Failed', - 'scanning': 'Scanning...', 'matched': 'Matched', - 'rejected': 'Dismissed', 'approved': 'Approved', - 'processing': 'Processing', - }; - const statusIcons = { - 'completed': '\u2713', 'pending_review': '\u26A0', - 'needs_identification': '\u2717', 'failed': '\u2717', - 'scanning': '\u231B', 'matched': '\u2713', - 'rejected': '\u2715', 'approved': '\u2713', - 'processing': '\u29D7', - }; - const statusLabel = statusLabels[r.status] || r.status; - const statusIcon = statusIcons[r.status] || ''; - const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' : - r.status === 'failed' || r.status === 'needs_identification' ? 'failed' : - r.status === 'processing' ? 'processing' : 'neutral'; - - // Live per-track progress for the row currently being processed. - // Match by folder_hash through the `active_imports` array - // — the worker now runs multiple imports in parallel via a - // bounded executor pool, so `current_folder` alone can't - // identify a row's live state. - const liveStatus = _autoImportLastStatus; - const liveActive = (liveStatus && Array.isArray(liveStatus.active_imports)) - ? liveStatus.active_imports.find(a => a.folder_hash === r.folder_hash) - : null; - const isLiveProcessing = r.status === 'processing' - && liveActive && liveActive.status === 'processing'; - const liveTrackIdx = isLiveProcessing ? (liveActive.track_index || 0) : 0; - const liveTrackTotal = isLiveProcessing ? (liveActive.track_total || 0) : 0; - const liveTrackName = isLiveProcessing ? (liveActive.track_name || '') : ''; - - // Parse match data for track details - let matchCount = 0, totalTracks = 0, trackDetails = []; - if (r.match_data) { - try { - const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data; - matchCount = md.matched_count || 0; - totalTracks = md.total_tracks || 0; - if (md.matches) { - trackDetails = md.matches.map(m => ({ - name: m.track_name || m.track?.name || 'Unknown', - file: m.file ? m.file.split(/[/\\]/).pop() : '?', - confidence: Math.round((m.confidence || 0) * 100), - })); - } - } catch (e) {} - } - - let matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`; - if (isLiveProcessing && liveTrackTotal > 0) { - matchSummary = `track ${liveTrackIdx}/${liveTrackTotal}: ${liveTrackName}`; - } - const methodLabels = { tags: 'Tags', folder_name: 'Folder Name', acoustid: 'AcoustID', filename: 'Filename' }; - const methodLabel = methodLabels[r.identification_method] || r.identification_method || ''; - - // Time ago - let timeAgo = ''; - if (r.created_at) { - try { - const d = new Date(r.created_at); - const diffM = Math.floor((Date.now() - d) / 60000); - if (diffM < 1) timeAgo = 'just now'; - else if (diffM < 60) timeAgo = `${diffM}m ago`; - else if (diffM < 1440) timeAgo = `${Math.floor(diffM / 60)}h ago`; - else timeAgo = `${Math.floor(diffM / 1440)}d ago`; - } catch (e) {} - } - - let actions = ''; - if (r.status === 'pending_review') { - actions = `
- - -
`; - } - - // Expanded track list (hidden by default) - let trackListHtml = ''; - if (trackDetails.length > 0) { - trackListHtml = `
-
- TrackMatched FileConf -
- ${trackDetails.map((t, tIdx) => { - const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low'; - // 1-based liveTrackIdx — current row glows, prior rows dim as "done". - let rowState = ''; - if (isLiveProcessing && liveTrackIdx > 0) { - if (tIdx + 1 === liveTrackIdx) rowState = ' auto-import-track-row-active'; - else if (tIdx + 1 < liveTrackIdx) rowState = ' auto-import-track-row-done'; - } - return `
- ${escapeHtml(t.name)} - ${escapeHtml(t.file)} - ${t.confidence}% -
`; - }).join('')} -
`; - } - - return `
-
-
- ${r.image_url ? `` : `
\uD83D\uDCBF
`} -
-
-
${escapeHtml(r.album_name || r.folder_name)}
-
${escapeHtml(r.artist_name || 'Unknown Artist')}
-
- ${matchSummary} - ${methodLabel ? `${methodLabel}` : ''} - ${timeAgo ? `${timeAgo}` : ''} -
- ${r.error_message ? `
${escapeHtml(r.error_message)}
` : ''} -
-
-
${statusIcon} ${statusLabel}
-
-
-
-
${confPct}% confidence
- ${actions} -
-
-
${escapeHtml(r.folder_name)}
- ${trackListHtml} -
`; - }).join(''); - - } catch (e) {} -} - -async function _autoImportSaveSettings() { - const confidence = (document.getElementById('auto-import-confidence')?.value || 90) / 100; - const interval = parseInt(document.getElementById('auto-import-interval')?.value || 60); - try { - await fetch('/api/auto-import/settings', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ confidence_threshold: confidence, scan_interval: interval }) - }); - showToast('Settings saved', 'success'); - } catch (e) { showToast('Error', 'error'); } -} - -function _autoImportSetFilter(filter) { - _autoImportFilter = filter; - document.querySelectorAll('#auto-import-filters .adl-pill').forEach(p => - p.classList.toggle('active', p.dataset.filter === filter)); - _autoImportLoadResults(); -} - -async function _autoImportScanNow() { - try { - const res = await fetch('/api/auto-import/scan-now', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast('Scan triggered', 'success'); - _autoImportLoadStatus(); - } else { - showToast(data.error || 'Failed to trigger scan', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -async function _autoImportApproveAll() { - const confirmed = await showConfirmDialog({ - title: 'Approve All', - message: 'Approve and import all pending review items?', - confirmText: 'Approve All', - }); - if (!confirmed) return; - try { - const res = await fetch('/api/auto-import/approve-all', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Approved ${data.count || 0} items`, 'success'); - _autoImportLoadResults(); - } else { - showToast(data.error || 'Failed', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -async function _autoImportClearCompleted() { - try { - const res = await fetch('/api/auto-import/clear-completed', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Cleared ${data.count || 0} imported items`, 'success'); - _autoImportLoadResults(); - } else { - showToast(data.error || 'Failed', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -function _autoImportToggleDetail(idx) { - const trackList = document.getElementById(`auto-import-tracks-${idx}`); - if (trackList) { - trackList.classList.toggle('expanded'); - } -} -window._autoImportToggleDetail = _autoImportToggleDetail; -window._autoImportSetFilter = _autoImportSetFilter; -window._autoImportScanNow = _autoImportScanNow; -window._autoImportApproveAll = _autoImportApproveAll; -window._autoImportClearCompleted = _autoImportClearCompleted; - -async function _autoImportApprove(id) { - try { - const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { showToast('Approved', 'success'); _autoImportLoadResults(); } - else showToast(data.error || 'Failed', 'error'); - } catch (e) { showToast('Error', 'error'); } -} - -async function _autoImportReject(id) { - try { - const res = await fetch(`/api/auto-import/reject/${id}`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { showToast('Dismissed', 'success'); _autoImportLoadResults(); } - else showToast(data.error || 'Failed', 'error'); - } catch (e) { showToast('Error', 'error'); } -} - -// --- Album Tab: Auto-Detected Groups (from file tags) --- - -async function importPageLoadAutoGroups() { - const grid = document.getElementById('import-page-suggestions-grid'); - if (!grid) return; - - try { - const resp = await fetch('/api/import/staging/groups'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.success || !data.groups || data.groups.length === 0) return; - - // Build auto-groups section above suggestions - let groupsContainer = document.getElementById('import-page-auto-groups'); - if (!groupsContainer) { - groupsContainer = document.createElement('div'); - groupsContainer.id = 'import-page-auto-groups'; - groupsContainer.style.marginBottom = '16px'; - const suggestionsSection = document.getElementById('import-page-suggestions'); - if (suggestionsSection) { - suggestionsSection.parentNode.insertBefore(groupsContainer, suggestionsSection); - } else { - grid.parentNode.insertBefore(groupsContainer, grid); - } - } - - groupsContainer.innerHTML = ` -
- Auto-Detected Albums -
-
- ${data.groups.map((g, idx) => ` -
-
- ${g.file_count} -
-
-
${_esc(g.album)}
-
${_esc(g.artist)} · ${g.file_count} tracks
-
-
- `).join('')} -
- `; - - // Store groups for click handler - importPageState._autoGroups = data.groups; - } catch (err) { - console.warn('Failed to load auto-groups:', err); - } -} - -async function importPageMatchAutoGroup(groupIdx) { - const group = importPageState._autoGroups?.[groupIdx]; - if (!group) return; - - // Search for the album by name + artist - const query = `${group.artist} ${group.album}`; - const searchInput = document.getElementById('import-page-album-search-input'); - if (searchInput) searchInput.value = query; - - // Hide suggestions/groups, show search results - const suggestionsEl = document.getElementById('import-page-suggestions'); - const groupsEl = document.getElementById('import-page-auto-groups'); - if (suggestionsEl) suggestionsEl.style.display = 'none'; - if (groupsEl) groupsEl.style.display = 'none'; - - const grid = document.getElementById('import-page-album-results'); - if (grid) grid.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); - const data = await resp.json(); - - if (data.success && data.albums && data.albums.length > 0) { - // Store file_paths filter so match only includes this group's files - importPageState._autoGroupFilePaths = group.file_paths; - - // Render results — user picks the right album - const banner = _renderImportFallbackBanner(data.albums, data.primary_source); - grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - } else { - grid.innerHTML = '
No albums found — try searching manually
'; - } - } catch (err) { - console.error('Auto-group search failed:', err); - if (grid) grid.innerHTML = '
Search failed
'; - } -} - -// --- Album Tab: Suggestions (server-side cache, just fetch and render) --- - -async function importPageLoadSuggestions() { - const section = document.getElementById('import-page-suggestions'); - const grid = document.getElementById('import-page-suggestions-grid'); - if (!section || !grid) return; - - try { - const resp = await fetch('/api/import/staging/suggestions'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.success || !data.suggestions || data.suggestions.length === 0) { - if (!data.ready) { - // Server is still building cache — show placeholder, retry shortly - section.style.display = ''; - grid.innerHTML = '
Loading suggestions...
'; - setTimeout(() => importPageLoadSuggestions(), 3000); - } else { - section.style.display = 'none'; - grid.innerHTML = ''; - } - return; - } - - section.style.display = ''; - const banner = _renderImportFallbackBanner(data.suggestions, data.primary_source); - grid.innerHTML = banner + data.suggestions.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - } catch (err) { - // Network error or server not ready — fail silently - console.warn('Failed to load import suggestions:', err); - } -} - -function _renderSuggestionCard(a, primarySource) { - // Cache the album lookup so importPageSelectAlbum can pull source + - // name + artist on click (the onclick can only carry the ID string - // — see github issue #524 root cause). - importPageState._albumLookup[a.id] = { - id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '', - }; - // Surface the served source when it differs from the user's configured - // primary — the search route silently falls through to the next source - // in METADATA_SOURCE_PRIORITY when the primary returns nothing - // (intentional design, see core/auto_import_worker.py:1316). Without - // this badge the user has no idea their MusicBrainz / Discogs choice - // got bypassed (github issue #681). - const sourceBadge = (a.source && primarySource && a.source !== primarySource) - ? `
via ${_esc((SOURCE_LABELS[a.source] || {}).text || a.source)}
` - : ''; - const metaParts = [ - `${a.total_tracks || 0} tracks`, - a.release_date ? a.release_date.substring(0, 4) : '', - a.format || '', - a.country || '', - a.disambiguation || '', - ].filter(Boolean); - const details = [a.status || '', a.label || ''].filter(Boolean); - const detailsLine = details.length - ? `
${_esc(details.join(' · '))}
` - : ''; - return `
- ${_escAttr(a.name)} -
${_esc(a.name)}
-
${_esc(a.artist)}
-
${_esc(metaParts.join(' · '))}
- ${detailsLine} - ${sourceBadge} -
`; -} - -function _renderImportFallbackBanner(albums, primarySource) { - if (!primarySource || !albums || !albums.length) return ''; - const allFallback = albums.every(a => a.source && a.source !== primarySource); - if (!allFallback) return ''; - const servedSource = albums[0].source; - const primaryLabel = (SOURCE_LABELS[primarySource] || {}).text || primarySource; - const servedLabel = (SOURCE_LABELS[servedSource] || {}).text || servedSource; - // Neutral wording — covers both live-search fallback (primary returned 0) - // and cache-stale suggestions (primary changed since cache was built). - return `
Showing ${_esc(servedLabel)} results — not from your primary source (${_esc(primaryLabel)}).
`; -} - -// --- Album Tab: Search --- - -async function importPageSearchAlbum() { - const query = document.getElementById('import-page-album-search-input').value.trim(); - if (!query) return; - - document.getElementById('import-page-suggestions').style.display = 'none'; - const groupsEl = document.getElementById('import-page-auto-groups'); - if (groupsEl) groupsEl.style.display = 'none'; - const grid = document.getElementById('import-page-album-results'); - grid.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); - const data = await resp.json(); - if (!data.success || !data.albums.length) { - grid.innerHTML = '
No albums found
'; - return; - } - const banner = _renderImportFallbackBanner(data.albums, data.primary_source); - grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - document.getElementById('import-page-album-clear-btn').classList.remove('hidden'); - } catch (err) { - grid.innerHTML = `
Error: ${err.message}
`; - } -} - -// --- Album Tab: Select Album & Match --- - -async function importPageSelectAlbum(albumId) { - document.getElementById('import-page-album-search-section').classList.add('hidden'); - document.getElementById('import-page-album-match-section').classList.remove('hidden'); - - const matchList = document.getElementById('import-page-match-list'); - matchList.innerHTML = '
Matching files to tracklist...
'; - - try { - // Include file_paths filter if matching from an auto-group. - // CRITICAL: include source + album_name + album_artist from the - // search/suggestion result. Without `source`, the backend can't - // route the lookup to the metadata source the album_id came - // from — for example a Deezer album_id needs Deezer's get_album - // call. Cross-source lookup fails silently, returns the - // failure-fallback dict with album_id-as-name + Unknown Artist - // + 0 tracks, then the import flow writes that broken metadata - // to the library DB (github issue #524). - const cached = importPageState._albumLookup[albumId] || {}; - const matchBody = { - album_id: albumId, - source: cached.source || '', - album_name: cached.name || '', - album_artist: cached.artist || '', - }; - if (importPageState._autoGroupFilePaths) { - matchBody.file_paths = importPageState._autoGroupFilePaths; - importPageState._autoGroupFilePaths = null; // clear after use - } - const resp = await fetch('/api/import/album/match', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(matchBody) - }); - const data = await resp.json(); - if (!data.success) { - matchList.innerHTML = `
Error: ${data.error}
`; - return; - } - - importPageState.albumData = data; - importPageState.matchOverrides = {}; - - // Render hero - const album = data.album; - const heroMetaParts = [ - `${album.total_tracks || 0} tracks`, - album.release_date ? album.release_date.substring(0, 4) : '', - album.format || '', - album.country || '', - album.disambiguation || '', - ].filter(Boolean); - document.getElementById('import-page-album-hero').innerHTML = ` - ${_escAttr(album.name)} -
-
${_esc(album.name)}
-
${_esc(album.artist)}
-
${_esc(heroMetaParts.join(' · '))}
-
- `; - - importPageRenderMatchList(); - } catch (err) { - matchList.innerHTML = `
Error: ${err.message}
`; - } -} - -function importPageRenderMatchList() { - const data = importPageState.albumData; - if (!data) return; - - const matchList = document.getElementById('import-page-match-list'); - const overrides = importPageState.matchOverrides; - - // Build effective matches: auto-match overridden by manual overrides - // Also track which staging files are used (auto or override) - const usedStagingFiles = new Set(); - - // First pass: collect overridden indices - Object.values(overrides).forEach(sfIdx => usedStagingFiles.add(sfIdx)); - - // Build rows - let matchedCount = 0; - const rows = data.matches.map((m, idx) => { - const trackInfo = _importPageGetTrackDisplayInfo(m, idx); - let file = null; - let confidence = m.confidence; - let isOverride = false; - - if (overrides.hasOwnProperty(idx)) { - const sfIdx = overrides[idx]; - if (sfIdx === -1) { - // Forcibly unmatched — no file - file = null; - } else { - // Manual override - file = importPageState.stagingFiles[sfIdx] || null; - confidence = 1.0; - isOverride = true; - usedStagingFiles.add(sfIdx); - } - } else if (m.staging_file) { - file = m.staging_file; - // Check if this file was reassigned to another track via override - const autoFileName = m.staging_file.filename; - const reassigned = Object.entries(overrides).some(([tIdx, sfIdx]) => { - const sf = importPageState.stagingFiles[sfIdx]; - return sf && sf.filename === autoFileName && parseInt(tIdx) !== idx; - }); - if (!reassigned) { - usedStagingFiles.add(-1); // placeholder — auto-matched file - } else { - file = null; // file was reassigned elsewhere - } - } - - if (file) matchedCount++; - const confPercent = Math.round(confidence * 100); - const confClass = confidence >= 0.7 ? '' : 'low'; - - return ` -
- ${trackInfo.displayTrackNumber} - ${_esc(trackInfo.name)} - - ${file - ? `${_esc(file.filename)} - ${confPercent}%` - : `Drop a file here`} - - ${file ? `` : ''} -
- `; - }); - - matchList.innerHTML = rows.join(''); - - // Unmatched file pool - const unmatchedFiles = []; - importPageState.stagingFiles.forEach((f, i) => { - // Check if used by override - if (Object.values(overrides).includes(i)) return; - // Check if used by auto-match (not overridden away) - const autoUsed = data.matches.some((m, mIdx) => { - if (overrides.hasOwnProperty(mIdx)) return false; - return m.staging_file && m.staging_file.filename === f.filename; - }); - if (autoUsed) return; - unmatchedFiles.push({ file: f, index: i }); - }); - - const poolChips = document.getElementById('import-page-pool-chips'); - document.getElementById('import-page-unmatched-count').textContent = unmatchedFiles.length; - - if (unmatchedFiles.length === 0) { - poolChips.innerHTML = 'All files matched'; - } else { - poolChips.innerHTML = unmatchedFiles.map(({ file, index }) => ` - - ${_esc(file.filename)} - - `).join(''); - } - - // Stats & button - document.getElementById('import-page-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`; - const processBtn = document.getElementById('import-page-album-process-btn'); - processBtn.disabled = matchedCount === 0; - processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`; -} - -function _importPageGetTrackDisplayInfo(item, index) { - const track = item?.track || item?.spotify_track || {}; - const rawTrackNumber = track.track_number ?? track.trackNumber ?? null; - const trackNumber = rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === '' - ? null - : String(rawTrackNumber).split('/')[0].trim(); - - return { - track, - name: track.name || track.title || `Track ${index + 1}`, - trackNumber, - displayTrackNumber: trackNumber || String(index + 1), - }; -} - -// --- Album Tab: Drag and Drop --- - -function importPageStartDrag(event, stagingFileIndex) { - event.dataTransfer.setData('text/plain', stagingFileIndex.toString()); - event.dataTransfer.effectAllowed = 'move'; -} - -function importPageHandleDragOver(event) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - event.currentTarget.classList.add('drag-over'); - // Remove drag-over from others - document.querySelectorAll('.import-page-match-row.drag-over').forEach(el => { - if (el !== event.currentTarget) el.classList.remove('drag-over'); - }); -} - -function importPageHandleDrop(event, trackIndex) { - event.preventDefault(); - event.currentTarget.classList.remove('drag-over'); - const stagingFileIndex = parseInt(event.dataTransfer.getData('text/plain')); - if (isNaN(stagingFileIndex)) return; - - // Remove this staging file from any other track it was assigned to - Object.keys(importPageState.matchOverrides).forEach(k => { - if (importPageState.matchOverrides[k] === stagingFileIndex) { - delete importPageState.matchOverrides[k]; - } - }); - - importPageState.matchOverrides[trackIndex] = stagingFileIndex; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -// Mobile tap-to-assign fallback -function importPageTapSelectChip(stagingFileIndex) { - if (importPageState.tapSelectedChip === stagingFileIndex) { - importPageState.tapSelectedChip = null; - } else { - importPageState.tapSelectedChip = stagingFileIndex; - } - importPageRenderMatchList(); -} - -function importPageTapAssign(trackIndex) { - if (importPageState.tapSelectedChip === null) return; - const stagingFileIndex = importPageState.tapSelectedChip; - - // Remove from any other track - Object.keys(importPageState.matchOverrides).forEach(k => { - if (importPageState.matchOverrides[k] === stagingFileIndex) { - delete importPageState.matchOverrides[k]; - } - }); - - importPageState.matchOverrides[trackIndex] = stagingFileIndex; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -function importPageUnmatchTrack(trackIndex) { - delete importPageState.matchOverrides[trackIndex]; - // Also remove auto-match by setting override to -1 special value? No — just delete override and let auto-match stay. - // Actually, to truly unmatch: we need to suppress the auto-match too. - // We'll use a sentinel: override = -1 means "forcibly unmatched" - const m = importPageState.albumData?.matches[trackIndex]; - if (m && m.staging_file) { - importPageState.matchOverrides[trackIndex] = -1; // sentinel: force no match - } - importPageRenderMatchList(); -} - -function importPageAutoRematch() { - importPageState.matchOverrides = {}; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -// --- Album Tab: Process --- - -function importPageProcessAlbum() { - const data = importPageState.albumData; - if (!data) return; - - // Build effective matches with overrides applied - const overrides = importPageState.matchOverrides; - const effectiveMatches = []; - data.matches.forEach((m, idx) => { - if (overrides.hasOwnProperty(idx)) { - if (overrides[idx] === -1) return; // forcibly unmatched — skip - const sf = importPageState.stagingFiles[overrides[idx]]; - effectiveMatches.push({ ...m, staging_file: sf, confidence: 1.0 }); - } else if (m.staging_file !== null) { - effectiveMatches.push(m); - } - }); - - if (effectiveMatches.length === 0) return; - - // Add to queue and reset search immediately so user can queue more - const album = data.album; - _importQueueAdd({ - type: 'album', - label: album.name, - sublabel: `${album.artist} · ${effectiveMatches.length} tracks`, - imageUrl: album.image_url, - items: effectiveMatches, - albumData: album, - }); - - importPageResetAlbumSearch(); -} - -function importPageResetAlbumSearch() { - importPageState.albumData = null; - importPageState.matchOverrides = {}; - importPageState.tapSelectedChip = null; - importPageState._autoGroupFilePaths = null; - - document.getElementById('import-page-album-search-section').classList.remove('hidden'); - document.getElementById('import-page-album-match-section').classList.add('hidden'); - - // Clear search - document.getElementById('import-page-album-results').innerHTML = ''; - document.getElementById('import-page-album-search-input').value = ''; - document.getElementById('import-page-album-clear-btn').classList.add('hidden'); - - // Re-show auto-groups - const groupsEl = document.getElementById('import-page-auto-groups'); - if (groupsEl) groupsEl.style.display = ''; - - // Refresh suggestions & staging - importPageLoadAutoGroups(); - importPageLoadSuggestions(); - importPageRefreshStaging(); -} - -// --- Singles Tab --- - -function importPageRenderSinglesList() { - const list = document.getElementById('import-page-singles-list'); - const files = importPageState.stagingFiles; - - if (files.length === 0) { - list.innerHTML = '
No audio files found in import folder
'; - return; - } - - list.innerHTML = files.map((f, i) => { - const isSelected = importPageState.selectedSingles.has(i); - const manualMatch = importPageState.singlesManualMatches[i]; - const searchOpen = document.querySelector(`[data-singles-search="${i}"]`); - - let html = ` -
-
-
-
${_esc(f.filename)}
-
- ${f.title ? `${_esc(f.title)}` : ''} - ${f.artist ? `${_esc(f.artist)}` : ''} - ${f.extension ? `${f.extension}` : ''} -
- ${manualMatch ? ` -
- ✓ ${_esc(manualMatch.name)} - ${_esc(manualMatch.artist)} - change -
- ` : ''} -
-
- -
-
- `; - return html; - }).join(''); - - importPageUpdateSinglesProcessButton(); -} - -function importPageToggleSingle(idx) { - if (importPageState.selectedSingles.has(idx)) { - importPageState.selectedSingles.delete(idx); - } else { - importPageState.selectedSingles.add(idx); - } - // Update checkbox UI without full re-render - const item = document.querySelector(`[data-single-idx="${idx}"]`); - if (item) { - const cb = item.querySelector('.import-page-single-checkbox'); - if (cb) cb.classList.toggle('checked', importPageState.selectedSingles.has(idx)); - } - importPageUpdateSinglesProcessButton(); -} - -function importPageSelectAllSingles() { - const allSelected = importPageState.selectedSingles.size === importPageState.stagingFiles.length; - if (allSelected) { - importPageState.selectedSingles.clear(); - } else { - importPageState.stagingFiles.forEach((_, i) => importPageState.selectedSingles.add(i)); - } - document.getElementById('import-page-select-all-text').textContent = allSelected ? 'Select All' : 'Deselect All'; - // Update all checkboxes - document.querySelectorAll('.import-page-single-checkbox').forEach((cb, i) => { - cb.classList.toggle('checked', importPageState.selectedSingles.has(i)); - }); - importPageUpdateSinglesProcessButton(); -} - -function importPageUpdateSinglesProcessButton() { - const btn = document.getElementById('import-page-singles-process-btn'); - const count = importPageState.selectedSingles.size; - btn.textContent = `Process Selected (${count})`; - btn.disabled = count === 0; -} - -function importPageOpenSingleSearch(fileIdx) { - const item = document.querySelector(`[data-single-idx="${fileIdx}"]`); - if (!item) return; - - // Remove any existing search panel - const existing = item.querySelector('.import-page-single-search-panel'); - if (existing) { - existing.remove(); - return; - } - - // Close other open panels - document.querySelectorAll('.import-page-single-search-panel').forEach(p => p.remove()); - - const f = importPageState.stagingFiles[fileIdx]; - const defaultQuery = [f.artist, f.title].filter(Boolean).join(' ') || f.filename.replace(/\.[^.]+$/, ''); - - const panel = document.createElement('div'); - panel.className = 'import-page-single-search-panel'; - panel.innerHTML = ` - -
- `; - item.appendChild(panel); - - // Auto-search - const input = panel.querySelector('input'); - input.focus(); - if (defaultQuery) { - importPageSearchSingleTrack(fileIdx, defaultQuery); - } -} - -async function importPageSearchSingleTrack(fileIdx, query) { - if (!query || !query.trim()) return; - - const resultsDiv = document.getElementById(`import-single-results-${fileIdx}`); - if (!resultsDiv) return; - resultsDiv.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/tracks?q=${encodeURIComponent(query.trim())}&limit=6`); - const data = await resp.json(); - if (!data.success || !data.tracks.length) { - resultsDiv.innerHTML = '
No results found
'; - return; - } - // Store results in a temp cache so we can reference by index - window._importSingleSearchResults = window._importSingleSearchResults || {}; - window._importSingleSearchResults[fileIdx] = data.tracks; - - resultsDiv.innerHTML = data.tracks.map((t, tIdx) => { - const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; - return ` -
- ${t.image_url ? `` : ''} -
-
${_esc(t.name)} - ${_esc(t.artist)}
-
${_esc(t.album)}${dur ? ' · ' + dur : ''}
-
- -
- `; - }).join(''); - } catch (err) { - resultsDiv.innerHTML = `
Error: ${err.message}
`; - } -} - -function importPageSelectSingleMatch(fileIdx, trackIdx) { - const trackData = window._importSingleSearchResults?.[fileIdx]?.[trackIdx]; - if (!trackData) return; - importPageState.singlesManualMatches[fileIdx] = trackData; - - // Auto-select this file - importPageState.selectedSingles.add(fileIdx); - - // Close search panel and re-render this item - importPageRenderSinglesList(); -} - -// --- Singles Tab: Process --- - -function importPageProcessSingles() { - if (importPageState.selectedSingles.size === 0) return; - - const filesToProcess = Array.from(importPageState.selectedSingles).map(i => { - const f = importPageState.stagingFiles[i]; - const manualMatch = importPageState.singlesManualMatches[i]; - if (manualMatch) { - return { ...f, manual_match: manualMatch }; - } - return f; - }); - - // Add to queue and reset immediately - _importQueueAdd({ - type: 'singles', - label: `${filesToProcess.length} Single${filesToProcess.length !== 1 ? 's' : ''}`, - sublabel: filesToProcess.map(f => f.title || f.filename).slice(0, 3).join(', ') + (filesToProcess.length > 3 ? '...' : ''), - imageUrl: null, - items: filesToProcess, - }); - - importPageState.selectedSingles.clear(); - importPageState.singlesManualMatches = {}; - importPageUpdateSinglesProcessButton(); - importPageRefreshStaging(); -} - -// --- Processing Queue --- - -const _importQueue = []; // { id, type, label, sublabel, imageUrl, status, processed, total, errors } - -function _importQueueAdd(job) { - const id = ++importJobIdCounter; - const entry = { - id, - type: job.type, - label: job.label, - sublabel: job.sublabel, - imageUrl: job.imageUrl, - status: 'running', // running | done | error - processed: 0, - total: job.items.length, - errors: [], - }; - _importQueue.push(entry); - _importQueueRender(); - - // Fire and forget — runs in background - _importQueueRunJob(entry, job); -} - -async function _importQueueRunJob(entry, job) { - for (let i = 0; i < job.items.length; i++) { - const itemName = job.type === 'album' - ? _importPageGetTrackDisplayInfo(job.items[i], i).name - : (job.items[i].title || job.items[i].filename || `File ${i + 1}`); - - // Update status with current track info - entry.sublabel = `Processing ${i + 1}/${job.items.length}: ${itemName}`; - _importQueueRender(); - - try { - let resp; - if (job.type === 'album') { - resp = await fetch('/api/import/album/process', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - album: job.albumData, - matches: [job.items[i]] - }) - }); - } else { - resp = await fetch('/api/import/singles/process', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files: [job.items[i]] }) - }); - } - - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - if (data.success) entry.processed += (data.processed || 0); - if (data.errors && data.errors.length > 0) entry.errors.push(...data.errors); - } catch (err) { - entry.errors.push(`${itemName}: ${err.message}`); - } - - _importQueueRender(); - } - - entry.status = entry.errors.length > 0 && entry.processed === 0 ? 'error' : 'done'; - _importQueueRender(); - - // Refresh staging and suggestions since files moved - importPageRefreshStaging(); - importPageLoadSuggestions(); -} - -function _importQueueRender() { - const container = document.getElementById('import-page-queue'); - const list = document.getElementById('import-page-queue-list'); - const clearBtn = document.getElementById('import-page-queue-clear'); - if (!container || !list) return; - - if (_importQueue.length === 0) { - container.classList.add('hidden'); - return; - } - - container.classList.remove('hidden'); - - // Show clear button only if there are finished jobs - const hasFinished = _importQueue.some(j => j.status !== 'running'); - clearBtn.style.display = hasFinished ? '' : 'none'; - - list.innerHTML = _importQueue.map(j => { - const pct = j.total > 0 ? Math.round((j.processed / j.total) * 100) : 0; - const fillClass = j.status === 'error' ? 'error' : ''; - let statusText, statusClass; - if (j.status === 'running') { - statusText = `${j.processed}/${j.total}`; - statusClass = ''; - } else if (j.status === 'done') { - statusText = j.errors.length > 0 ? `${j.processed}/${j.total} (${j.errors.length} err)` : 'Done'; - statusClass = j.errors.length > 0 ? 'error' : 'done'; - } else { - statusText = 'Failed'; - statusClass = 'error'; - } - - return ` -
- ${j.imageUrl - ? `` - : `
`} -
-
${_esc(j.label)}
-
${_esc(j.sublabel)}
-
-
-
-
-
-
${statusText}
-
-
- `; - }).join(''); -} - -function importPageClearFinishedJobs() { - for (let i = _importQueue.length - 1; i >= 0; i--) { - if (_importQueue[i].status !== 'running') { - _importQueue.splice(i, 1); - } - } - _importQueueRender(); -} - // ── Import File Tab ────────────────────────────────────────────────── let _importFileState = { @@ -6056,14 +4682,15 @@ async function playArtistRadio() { const albumArt = random.album.thumb_url || data.artist?.thumb_url || null; // Clear existing queue and disable radio before starting fresh - npSetRadioMode(false, { toast: false }); + npRadioMode = false; clearQueue(); if (audioPlayer && !audioPlayer.paused) { audioPlayer.pause(); } - // Play the track first so currentTrack is populated before radio seeds the queue. - await playLibraryTrack({ + // Play the track first, then enable radio mode after a short delay + // so currentTrack is set and the radio queue fill triggers + playLibraryTrack({ id: random.track.id, title: random.track.title, file_path: random.track.file_path, @@ -6072,9 +4699,14 @@ async function playArtistRadio() { album_id: random.album.id, }, random.album.title || '', artistName); - npSetRadioMode(true, { toast: false, fetchIfNeeded: true }); + // Enable radio mode after track starts loading + setTimeout(() => { + npRadioMode = true; + const radioBtn = document.querySelector('.np-radio-btn'); + if (radioBtn) radioBtn.classList.add('active'); + }, 1000); - showToast(`Playing ${artistName} radio - similar tracks will auto-queue`, 'success'); + showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success'); } catch (e) { showToast(`Failed to start artist radio: ${e.message}`, 'error'); } diff --git a/webui/static/style.css b/webui/static/style.css index ea65ca7f..7706efc0 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -10376,8 +10376,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(167, 139, 250, 0.25); } -.library-history-badge.download.source-staging, -.library-history-badge.download.source-auto-import { +.library-history-badge.download.source-staging { color: rgba(255, 255, 255, 0.55); background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.12); @@ -40072,956 +40071,6 @@ div.artist-hero-badge { opacity: 1; } -/* ======================================== - IMPORT PAGE (full page, replaces modal) - ======================================== */ - -/* ============================================================================ - IMPORT PAGE - ============================================================================ */ - -.import-page-container { - max-width: 1200px; - margin: 0 auto; - padding: 24px 32px; -} - -.import-page-header { - margin-bottom: 20px; -} - -.import-page-title-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.import-page-title { - font-size: 28px; - font-weight: 700; - margin: 0; - letter-spacing: -0.3px; - font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; - display: flex; - align-items: center; - gap: 14px; -} - -.import-page-title > span { - background: linear-gradient(90deg, #ffffff 0%, rgb(var(--accent-light-rgb)) 50%, rgb(var(--accent-rgb)) 100%); - background-size: 200% 100%; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - animation: page-title-shimmer 6s ease-in-out infinite; -} - -.import-page-refresh-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 16px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #ccc; - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-refresh-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -.import-page-staging-bar { - display: flex; - align-items: center; - gap: 16px; - padding: 10px 16px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; - font-size: 13px; - color: rgba(255, 255, 255, 0.5); -} - -.import-staging-path { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.import-staging-stats { - color: rgb(var(--accent-light-rgb)); - font-weight: 500; - white-space: nowrap; -} - -/* Tab Bar */ -.import-page-tab-bar { - display: flex; - gap: 4px; - margin-bottom: 24px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - padding-bottom: 0; -} - -.import-page-tab { - padding: 10px 24px; - background: none; - border: none; - border-bottom: 2px solid transparent; - color: rgba(255, 255, 255, 0.5); - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - margin-bottom: -1px; -} - -.import-page-tab:hover { - color: rgba(255, 255, 255, 0.8); -} - -.import-page-tab.active { - color: rgb(var(--accent-light-rgb)); - border-bottom-color: rgb(var(--accent-light-rgb)); -} - -.import-page-tab-content { - display: none; -} - -.import-page-tab-content.active { - display: block; -} - -/* Section labels */ -.import-page-section-label { - font-size: 13px; - color: rgba(255, 255, 255, 0.4); - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 500; -} - -/* Search bar */ -.import-page-search-bar { - display: flex; - gap: 8px; - margin-bottom: 20px; - position: relative; -} - -.import-page-search-input { - flex: 1; - padding: 10px 16px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 10px; - color: #fff; - font-size: 14px; - outline: none; - transition: border-color 0.2s; -} - -.import-page-search-input:focus { - border-color: rgba(var(--accent-light-rgb), 0.5); -} - -.import-page-search-btn { - padding: 10px 20px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 10px; - color: #000; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: background 0.2s; - white-space: nowrap; -} - -.import-page-search-btn:hover { - background: #1fdf64; -} - -.import-page-clear-btn { - position: absolute; - right: 100px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: rgba(255, 255, 255, 0.4); - font-size: 16px; - cursor: pointer; - padding: 4px 8px; -} - -.import-page-clear-btn:hover { - color: #fff; -} - -/* Album grid */ -.import-page-album-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 16px; - margin-bottom: 24px; -} - -.import-page-album-card { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - padding: 12px; - cursor: pointer; - transition: all 0.2s; - text-align: center; -} - -.import-page-album-card:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(var(--accent-light-rgb), 0.3); - transform: translateY(-2px); -} - -.import-page-album-card img { - width: 100%; - aspect-ratio: 1; - object-fit: cover; - border-radius: 8px; - margin-bottom: 8px; -} - -.import-page-album-card-title { - font-size: 13px; - font-weight: 600; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 2px; -} - -.import-page-album-card-artist { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-meta { - font-size: 10px; - color: rgba(255, 255, 255, 0.3); - margin-top: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-detail { - font-size: 10px; - color: rgba(255, 255, 255, 0.36); - margin-top: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-source { - font-size: 10px; - color: rgba(255, 200, 100, 0.75); - margin-top: 2px; - font-style: italic; -} - -.import-page-fallback-banner { - grid-column: 1 / -1; - padding: 10px 14px; - margin-bottom: 12px; - background: rgba(255, 200, 100, 0.08); - border: 1px solid rgba(255, 200, 100, 0.25); - border-radius: 8px; - color: rgba(255, 220, 170, 0.9); - font-size: 12px; - line-height: 1.4; -} - -/* Album hero (selected album) */ -.import-page-album-hero { - display: flex; - gap: 20px; - padding: 20px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - margin-bottom: 20px; - align-items: center; -} - -.import-page-album-hero img { - width: 120px; - height: 120px; - border-radius: 10px; - object-fit: cover; - flex-shrink: 0; -} - -.import-page-album-hero-info { - flex: 1; - min-width: 0; -} - -.import-page-album-hero-title { - font-size: 22px; - font-weight: 700; - color: #fff; - margin-bottom: 4px; -} - -.import-page-album-hero-artist { - font-size: 15px; - color: rgba(255, 255, 255, 0.6); - margin-bottom: 4px; -} - -.import-page-album-hero-meta { - font-size: 12px; - color: rgba(255, 255, 255, 0.35); -} - -/* Match header */ -.import-page-match-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.import-page-match-header h3 { - font-size: 16px; - font-weight: 600; - color: #fff; - margin: 0; -} - -.import-page-match-actions { - display: flex; - gap: 8px; -} - -/* Match list */ -.import-page-match-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 16px; -} - -.import-page-match-row { - display: grid; - grid-template-columns: 36px 1fr 1fr 36px; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - min-height: 44px; - transition: all 0.2s; -} - -.import-page-match-row.drag-over { - background: rgba(var(--accent-light-rgb), 0.08); - border-color: rgba(var(--accent-light-rgb), 0.4); - box-shadow: 0 0 12px rgba(var(--accent-light-rgb), 0.15); -} - -.import-page-match-row.matched { - border-color: rgba(var(--accent-light-rgb), 0.2); -} - -.import-page-match-num { - font-size: 13px; - color: rgba(255, 255, 255, 0.3); - text-align: center; - font-weight: 500; -} - -.import-page-match-track { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-match-file { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - display: flex; - align-items: center; - gap: 6px; - overflow: hidden; -} - -.import-page-match-file.has-file { - color: rgb(var(--accent-light-rgb)); -} - -.import-page-match-file-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.import-page-match-confidence { - font-size: 10px; - padding: 2px 6px; - border-radius: 4px; - background: rgba(var(--accent-light-rgb), 0.15); - color: rgb(var(--accent-light-rgb)); - font-weight: 500; - white-space: nowrap; -} - -.import-page-match-confidence.low { - background: rgba(255, 165, 0, 0.15); - color: #ffa500; -} - -.import-page-match-drop-zone { - font-size: 11px; - color: rgba(255, 255, 255, 0.25); - font-style: italic; -} - -.import-page-match-unmatch { - background: none; - border: none; - color: rgba(255, 255, 255, 0.3); - font-size: 14px; - cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.import-page-match-unmatch:hover { - color: #ff4444; - background: rgba(255, 68, 68, 0.1); -} - -/* Unmatched file pool */ -.import-page-unmatched-pool { - padding: 16px; - background: rgba(255, 255, 255, 0.02); - border: 1px dashed rgba(255, 255, 255, 0.1); - border-radius: 12px; - margin-bottom: 16px; -} - -.import-page-pool-label { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - margin-bottom: 10px; - font-weight: 500; -} - -.import-page-pool-chips { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.import-page-file-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 20px; - font-size: 12px; - color: #ccc; - cursor: grab; - transition: all 0.2s; - user-select: none; -} - -.import-page-file-chip:active { - cursor: grabbing; -} - -.import-page-file-chip:hover { - background: rgba(255, 255, 255, 0.14); - border-color: rgba(var(--accent-light-rgb), 0.3); - color: #fff; -} - -.import-page-file-chip.selected { - background: rgba(var(--accent-light-rgb), 0.15); - border-color: rgba(var(--accent-light-rgb), 0.4); - color: rgb(var(--accent-light-rgb)); -} - -.import-page-pool-empty { - font-size: 12px; - color: rgba(255, 255, 255, 0.2); - font-style: italic; -} - -/* Match footer */ -.import-page-match-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 16px; - border-top: 1px solid rgba(255, 255, 255, 0.06); -} - -.import-page-match-stats { - font-size: 13px; - color: rgba(255, 255, 255, 0.5); -} - -/* Buttons */ -.import-page-process-btn { - padding: 10px 24px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 10px; - color: #000; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-process-btn:hover { - background: #1fdf64; -} - -.import-page-process-btn:disabled { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.3); - cursor: not-allowed; -} - -.import-page-secondary-btn { - padding: 8px 16px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #ccc; - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-secondary-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -.import-page-back-btn { - padding: 8px 16px; - background: none; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - color: rgba(255, 255, 255, 0.5); - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-back-btn:hover { - color: #fff; - border-color: rgba(255, 255, 255, 0.2); -} - -/* Singles */ -.import-page-singles-header { - display: flex; - align-items: center; - justify-content: flex-end; - margin-bottom: 16px; -} - -.import-page-singles-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.import-page-singles-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.import-page-single-item { - display: grid; - grid-template-columns: 32px 1fr auto; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - transition: all 0.2s; -} - -.import-page-single-item.matched { - border-color: rgba(var(--accent-light-rgb), 0.2); -} - -.import-page-single-checkbox { - width: 18px; - height: 18px; - border: 2px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - flex-shrink: 0; -} - -.import-page-single-checkbox.checked { - background: rgb(var(--accent-light-rgb)); - border-color: rgb(var(--accent-light-rgb)); -} - -.import-page-single-checkbox.checked::after { - content: '✓'; - color: #000; - font-size: 12px; - font-weight: 700; -} - -.import-page-single-info { - min-width: 0; -} - -.import-page-single-filename { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-single-meta { - font-size: 11px; - color: rgba(255, 255, 255, 0.35); - margin-top: 2px; - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.import-page-single-matched-info { - font-size: 12px; - color: rgb(var(--accent-light-rgb)); - margin-top: 4px; - display: flex; - align-items: center; - gap: 6px; -} - -.import-page-single-matched-change { - color: rgba(255, 255, 255, 0.4); - cursor: pointer; - font-size: 11px; - text-decoration: underline; -} - -.import-page-single-matched-change:hover { - color: #fff; -} - -.import-page-single-actions { - display: flex; - gap: 6px; - align-items: center; - flex-shrink: 0; -} - -.import-page-identify-btn { - padding: 6px 12px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - color: #ccc; - font-size: 12px; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 4px; -} - -.import-page-identify-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -/* Inline search panel for singles */ -.import-page-single-search-panel { - grid-column: 1 / -1; - padding: 12px 0 4px 44px; -} - -.import-page-single-search-bar { - display: flex; - gap: 8px; - margin-bottom: 10px; -} - -.import-page-single-search-input { - flex: 1; - padding: 8px 12px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #fff; - font-size: 13px; - outline: none; -} - -.import-page-single-search-input:focus { - border-color: rgba(var(--accent-light-rgb), 0.4); -} - -.import-page-single-search-go { - padding: 8px 14px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 8px; - color: #000; - font-size: 13px; - font-weight: 600; - cursor: pointer; -} - -.import-page-single-search-results { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 200px; - overflow-y: auto; -} - -.import-page-single-result-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 8px; - cursor: pointer; - transition: all 0.15s; -} - -.import-page-single-result-item:hover { - background: rgba(var(--accent-light-rgb), 0.08); - border-color: rgba(var(--accent-light-rgb), 0.25); -} - -.import-page-single-result-img { - width: 36px; - height: 36px; - border-radius: 4px; - object-fit: cover; - flex-shrink: 0; -} - -.import-page-single-result-info { - flex: 1; - min-width: 0; -} - -.import-page-single-result-name { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-single-result-detail { - font-size: 11px; - color: rgba(255, 255, 255, 0.4); -} - -.import-page-single-result-select { - padding: 4px 12px; - background: rgba(var(--accent-light-rgb), 0.15); - border: 1px solid rgba(var(--accent-light-rgb), 0.3); - border-radius: 6px; - color: rgb(var(--accent-light-rgb)); - font-size: 11px; - font-weight: 600; - cursor: pointer; - flex-shrink: 0; -} - -.import-page-single-result-select:hover { - background: rgba(var(--accent-light-rgb), 0.25); -} - -/* Empty state */ -.import-page-empty-state { - text-align: center; - padding: 60px 20px; - color: rgba(255, 255, 255, 0.3); - font-size: 14px; -} - -/* Suggestions */ -.import-page-suggestions { - margin-bottom: 24px; -} - -/* Processing Queue */ -.import-page-queue { - margin-bottom: 20px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - background: rgba(255, 255, 255, 0.02); - overflow: hidden; -} - -.import-page-queue-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); -} - -.import-page-queue-title { - font-size: 13px; - font-weight: 600; - color: rgba(255, 255, 255, 0.6); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.import-page-queue-clear { - background: none; - border: none; - color: rgba(255, 255, 255, 0.3); - font-size: 12px; - cursor: pointer; - padding: 2px 8px; -} - -.import-page-queue-clear:hover { - color: #fff; -} - -.import-page-queue-list { - display: flex; - flex-direction: column; -} - -.import-page-queue-item { - display: grid; - grid-template-columns: 44px 1fr auto; - gap: 12px; - align-items: center; - padding: 10px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); -} - -.import-page-queue-item:last-child { - border-bottom: none; -} - -.import-page-queue-art { - width: 44px; - height: 44px; - border-radius: 6px; - object-fit: cover; -} - -.import-page-queue-info { - min-width: 0; -} - -.import-page-queue-name { - font-size: 13px; - font-weight: 600; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-queue-detail { - font-size: 11px; - color: rgba(255, 255, 255, 0.4); - margin-top: 2px; -} - -.import-page-queue-progress { - width: 120px; - flex-shrink: 0; -} - -.import-page-queue-bar { - height: 4px; - background: rgba(255, 255, 255, 0.08); - border-radius: 2px; - overflow: hidden; - margin-bottom: 4px; -} - -.import-page-queue-fill { - height: 100%; - background: rgb(var(--accent-light-rgb)); - border-radius: 2px; - width: 0%; - transition: width 0.3s ease; -} - -.import-page-queue-fill.error { - background: #ef4444; -} - -.import-page-queue-status { - font-size: 10px; - color: rgba(255, 255, 255, 0.4); - text-align: right; -} - -.import-page-queue-status.done { - color: rgb(var(--accent-light-rgb)); -} - -.import-page-queue-status.error { - color: #ef4444; -} - -/* ======================================== - END IMPORT PAGE - ======================================== */ - /* ======================================== FAILED DOWNLOAD ERROR TOOLTIP ======================================== */ @@ -45706,10 +44755,6 @@ textarea.enhanced-meta-field-input { .discog-download-wrap { margin: 12px 0 4px; } -.artist-hero-actions .discog-download-wrap { margin: 0; } - -.artist-hero-actions .discog-btn-compact { margin-top: 0; } - .discog-download-btn { position: relative; display: flex; @@ -45757,112 +44802,6 @@ textarea.enhanced-meta-field-input { animation: discogShimmer 3s ease-in-out infinite; } -.artist-hero-actions { - gap: 10px; - align-items: center; -} - -.artist-hero-actions .library-artist-radio-btn, -.artist-hero-actions .library-artist-watchlist-btn, -.artist-hero-actions .discog-download-btn, -.artist-hero-actions .library-artist-enhance-btn { - --artist-action-rgb: var(--accent-rgb); - min-height: 38px; - width: auto; - max-width: none; - padding: 8px 14px 8px 12px; - gap: 8px; - border-radius: 999px; - color: rgba(255, 255, 255, 0.82); - background: - linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.13), rgba(var(--artist-action-rgb), 0.03)), - rgba(255, 255, 255, 0.035); - border: 1px solid rgba(var(--artist-action-rgb), 0.26); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - 0 8px 22px rgba(0, 0, 0, 0.22); - font-size: 12px; - font-weight: 650; - letter-spacing: 0; - line-height: 1; - backdrop-filter: blur(14px) saturate(1.25); - transform: none; -} - -.artist-hero-actions .library-artist-radio-btn { --artist-action-rgb: 79, 195, 247; } -.artist-hero-actions .library-artist-watchlist-btn { --artist-action-rgb: 255, 193, 7; } -.artist-hero-actions .discog-download-btn { --artist-action-rgb: var(--accent-rgb); } -.artist-hero-actions .library-artist-enhance-btn { --artist-action-rgb: 180, 132, 255; } - -.artist-hero-actions .library-artist-radio-btn::before, -.artist-hero-actions .library-artist-watchlist-btn::before, -.artist-hero-actions .library-artist-enhance-btn::before, -.artist-hero-actions .discog-download-btn::before { - content: ''; - width: 7px; - height: 7px; - border-radius: 999px; - position: relative; - inset: auto; - flex: 0 0 auto; - opacity: 1; - background: rgb(var(--artist-action-rgb)); - box-shadow: 0 0 14px rgba(var(--artist-action-rgb), 0.85); - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.artist-hero-actions .library-artist-radio-btn:hover, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled), -.artist-hero-actions .discog-download-btn:hover, -.artist-hero-actions .library-artist-enhance-btn:hover { - color: #ffffff; - border-color: rgba(var(--artist-action-rgb), 0.5); - background: - linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.22), rgba(var(--artist-action-rgb), 0.06)), - rgba(255, 255, 255, 0.055); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.12), - 0 12px 28px rgba(0, 0, 0, 0.28), - 0 0 0 1px rgba(var(--artist-action-rgb), 0.12); - transform: translateY(-1px); -} - -.artist-hero-actions .library-artist-radio-btn:hover::before, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled)::before, -.artist-hero-actions .discog-download-btn:hover::before, -.artist-hero-actions .library-artist-enhance-btn:hover::before { - transform: scale(1.25); - box-shadow: 0 0 18px rgba(var(--artist-action-rgb), 1); -} - -.artist-hero-actions .library-artist-watchlist-btn.watching { - color: #ffd761; - border-color: rgba(255, 193, 7, 0.52); - background: - linear-gradient(135deg, rgba(255, 193, 7, 0.24), rgba(255, 193, 7, 0.06)), - rgba(255, 255, 255, 0.04); -} - -.artist-hero-actions .library-artist-radio-btn .radio-icon, -.artist-hero-actions .library-artist-watchlist-btn .watchlist-icon, -.artist-hero-actions .discog-download-btn .discog-btn-icon, -.artist-hero-actions .library-artist-enhance-btn .enhance-icon { - font-size: 13px; - position: relative; - z-index: 1; - transform: none; -} - -.artist-hero-actions .library-artist-radio-btn:hover .radio-icon, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled) .watchlist-icon, -.artist-hero-actions .library-artist-enhance-btn:hover .enhance-icon { - transform: none; -} - -.artist-hero-actions .discog-btn-shimmer { - display: none; -} - @keyframes discogShimmer { 0%, 100% { left: -100%; } 50% { left: 150%; } @@ -47281,56 +46220,26 @@ textarea.enhanced-meta-field-input { /* Radio mode button */ .np-radio-btn { - --np-radio-rgb: 79, 195, 247; - position: relative; - overflow: hidden; - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.1), rgba(var(--np-radio-rgb), 0.025)), - rgba(255, 255, 255, 0.035); - border: 1px solid rgba(var(--np-radio-rgb), 0.22); - color: rgba(255, 255, 255, 0.62); + background: none; + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.35); font-size: 11px; - font-weight: 650; - line-height: 1; - padding: 7px 10px; - border-radius: 999px; + padding: 3px 8px; + border-radius: 6px; cursor: pointer; display: flex; align-items: center; - gap: 6px; - min-height: 30px; - transition: all 0.18s ease; + gap: 4px; + transition: all 0.15s ease; } .np-radio-btn:hover { - color: rgba(255, 255, 255, 0.9); - border-color: rgba(var(--np-radio-rgb), 0.42); - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.16), rgba(var(--np-radio-rgb), 0.04)), - rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.7); + border-color: rgba(255, 255, 255, 0.2); } .np-radio-btn.active { - color: #ffffff; - border-color: rgba(var(--np-radio-rgb), 0.55); - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.28), rgba(var(--np-radio-rgb), 0.07)), - rgba(255, 255, 255, 0.06); - box-shadow: 0 0 0 1px rgba(var(--np-radio-rgb), 0.08), 0 0 18px rgba(var(--np-radio-rgb), 0.12); -} -.np-radio-label { - position: relative; - z-index: 1; -} -.np-radio-pulse { - width: 6px; - height: 6px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.24); - box-shadow: none; - transition: all 0.18s ease; -} -.np-radio-btn.active .np-radio-pulse { - background: rgb(var(--np-radio-rgb)); - box-shadow: 0 0 12px rgba(var(--np-radio-rgb), 0.95); + color: rgb(var(--accent-rgb)); + border-color: rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.08); } .np-queue-header-actions { display: flex; @@ -50453,18 +49362,15 @@ tr.tag-diff-same { transition: transform 0.2s ease; } -.repair-master-toggle input, -.auto-import-toggle-label input { +.repair-master-toggle input { display: none; } -.repair-master-toggle input:checked + .repair-toggle-slider, -.auto-import-toggle-label input:checked + .repair-toggle-slider { +.repair-master-toggle input:checked + .repair-toggle-slider { background: var(--accent-color, #6366f1); } -.repair-master-toggle input:checked + .repair-toggle-slider::after, -.auto-import-toggle-label input:checked + .repair-toggle-slider::after { +.repair-master-toggle input:checked + .repair-toggle-slider::after { transform: translateX(20px); } @@ -59601,388 +58507,6 @@ body.reduce-effects *::after { .wl-orb-group.expanded { max-width: 100%; } } -/* ═══════════════════════════════════════════════════════════════════ - AUTO-IMPORT TAB - ═══════════════════════════════════════════════════════════════════ */ - -.auto-import-controls { - padding: 12px 0 16px; - border-bottom: 1px solid rgba(255,255,255,0.05); - margin-bottom: 16px; -} - -.auto-import-toggle-row { - display: flex; - align-items: center; - gap: 12px; -} - -.auto-import-toggle-label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 13px; - font-weight: 600; - color: rgba(255,255,255,0.7); -} - -.auto-import-toggle-label input { display: none; } - -.auto-import-status { - font-size: 12px; - font-weight: 500; - padding: 2px 10px; - border-radius: 6px; -} -.auto-import-status.active { color: #4ade80; background: rgba(74,222,128,0.1); } -.auto-import-status.scanning { color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb),0.1); } -.auto-import-status.disabled { color: rgba(255,255,255,0.3); } - -.auto-import-settings-row { - display: flex; - align-items: center; - gap: 16px; - margin-top: 10px; - flex-wrap: wrap; - font-size: 12px; - color: rgba(255,255,255,0.5); -} - -.auto-import-settings-row label { display: flex; align-items: center; gap: 6px; } -.auto-import-settings-row input[type="range"] { width: 100px; } -.auto-import-settings-row select { - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.1); - color: #fff; - border-radius: 6px; - padding: 3px 8px; - font-size: 12px; -} - -.auto-import-empty { - text-align: center; - padding: 40px 20px; - color: rgba(255,255,255,0.3); - font-size: 13px; -} - -/* Result cards */ -.auto-import-card { - display: flex; - flex-direction: column; - padding: 14px 16px; - background: rgba(255,255,255,0.02); - border: 1px solid rgba(255,255,255,0.06); - border-radius: 12px; - margin-bottom: 8px; - transition: all 0.2s; -} - -.auto-import-card-top { - display: flex; - gap: 14px; - align-items: center; -} - -.auto-import-card:hover { - background: rgba(255,255,255,0.04); - border-color: rgba(255,255,255,0.1); -} - -.auto-import-completed { border-left: 3px solid #4ade80; } -.auto-import-review { border-left: 3px solid #fbbf24; } -.auto-import-failed { border-left: 3px solid #f87171; } -.auto-import-processing { border-left: 3px solid #60a5fa; } - -.auto-import-card-art { - width: 56px; height: 56px; - border-radius: 8px; - object-fit: cover; - flex-shrink: 0; -} - -.auto-import-card-art-fallback { - width: 56px; height: 56px; - border-radius: 8px; - background: rgba(255,255,255,0.05); - display: flex; align-items: center; justify-content: center; - font-size: 22px; opacity: 0.3; flex-shrink: 0; -} - -.auto-import-card-center { flex: 1; min-width: 0; } - -.auto-import-card-album { - font-size: 14px; font-weight: 600; color: #fff; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-artist { - font-size: 12px; color: rgba(255,255,255,0.45); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-folder { - font-size: 10px; color: rgba(255,255,255,0.25); margin-top: 2px; -} - -.auto-import-match-info { - font-size: 10px; color: rgba(255,255,255,0.35); margin-top: 2px; -} - -.auto-import-card-error { - font-size: 10px; color: #f87171; margin-top: 2px; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-right { - display: flex; flex-direction: column; align-items: flex-end; gap: 4px; - flex-shrink: 0; min-width: 80px; -} - -.auto-import-confidence-bar { - width: 60px; height: 4px; - background: rgba(255,255,255,0.06); - border-radius: 2px; overflow: hidden; -} - -.auto-import-confidence-fill { height: 100%; border-radius: 2px; } -.auto-import-conf-high { background: #4ade80; } -.auto-import-conf-medium { background: #fbbf24; } -.auto-import-conf-low { background: #f87171; } - -.auto-import-confidence-text { - font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.5); -} - -/* Scan Now button */ -.auto-import-scan-now-btn { - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.1); - color: rgba(255,255,255,0.6); - font-size: 11px; - padding: 4px 12px; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - gap: 5px; - transition: all 0.15s; - margin-left: auto; -} - -.auto-import-scan-now-btn:hover { - background: rgba(255,255,255,0.1); - color: rgba(255,255,255,0.9); -} - -/* Live progress */ -.auto-import-progress { - margin-top: 8px; - padding: 8px 12px; - background: rgba(var(--accent-rgb), 0.04); - border: 1px solid rgba(var(--accent-rgb), 0.1); - border-radius: 8px; -} - -.auto-import-progress-text { - font-size: 11px; - color: rgba(255,255,255,0.6); - margin-bottom: 4px; -} - -.auto-import-progress-bar { - height: 3px; - background: rgba(255,255,255,0.06); - border-radius: 2px; - overflow: hidden; -} - -.auto-import-progress-fill { - height: 100%; - background: rgba(var(--accent-rgb), 0.6); - border-radius: 2px; - width: 100%; - animation: adlPulse 1.5s ease-in-out infinite; -} - -/* Stats summary */ -.auto-import-stats { - display: flex; - gap: 16px; - padding: 8px 0; - margin-bottom: 4px; -} - -.auto-import-stat { - font-size: 12px; - color: rgba(255,255,255,0.5); - font-weight: 500; -} - -.auto-import-stat-review { color: #fbbf24; } -.auto-import-stat-failed { color: #f87171; } - -/* Filter pills */ -.auto-import-filters { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 0; - margin-bottom: 8px; -} - -/* Batch action buttons */ -.auto-import-batch-btn { - font-size: 11px; - padding: 4px 12px; - border-radius: 6px; - border: 1px solid rgba(var(--accent-rgb), 0.3); - background: rgba(var(--accent-rgb), 0.08); - color: rgba(var(--accent-rgb), 1); - cursor: pointer; - transition: all 0.15s; -} - -.auto-import-batch-btn:hover { - background: rgba(var(--accent-rgb), 0.15); -} - -.auto-import-clear-btn { - border-color: rgba(255,255,255,0.1); - background: rgba(255,255,255,0.04); - color: rgba(255,255,255,0.5); -} - -.auto-import-clear-btn:hover { - background: rgba(255,255,255,0.08); - color: rgba(255,255,255,0.8); -} - -.auto-import-card-meta { - display: flex; gap: 8px; align-items: center; - font-size: 10px; color: rgba(255,255,255,0.3); margin-top: 3px; -} - -.auto-import-method-badge { - background: rgba(255,255,255,0.06); - padding: 1px 6px; - border-radius: 4px; - font-size: 9px; - color: rgba(255,255,255,0.45); - text-transform: uppercase; - letter-spacing: 0.3px; -} - -.auto-import-card-folder-path { - font-size: 9px; - color: rgba(255,255,255,0.15); - margin-top: 6px; - padding-top: 6px; - border-top: 1px solid rgba(255,255,255,0.03); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Expandable track list */ -.auto-import-track-list { - display: none; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid rgba(255,255,255,0.04); -} - -.auto-import-track-list.expanded { - display: block; -} - -.auto-import-track-list-header { - display: grid; - grid-template-columns: 1fr 1fr 50px; - gap: 8px; - font-size: 9px; - font-weight: 600; - color: rgba(255,255,255,0.3); - text-transform: uppercase; - letter-spacing: 0.5px; - padding-bottom: 4px; - margin-bottom: 4px; - border-bottom: 1px solid rgba(255,255,255,0.03); -} - -.auto-import-track-row { - display: grid; - grid-template-columns: 1fr 1fr 50px; - gap: 8px; - padding: 3px 0; - font-size: 11px; -} - -.auto-import-track-name { - color: rgba(255,255,255,0.7); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.auto-import-track-file { - color: rgba(255,255,255,0.3); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.auto-import-track-conf { - text-align: right; - font-weight: 600; - font-size: 10px; -} - -.auto-import-track-conf.auto-import-conf-high { color: #4ade80; } -.auto-import-track-conf.auto-import-conf-medium { color: #fbbf24; } -.auto-import-track-conf.auto-import-conf-low { color: #f87171; } - -.auto-import-status-badge { - font-size: 9px; font-weight: 600; padding: 2px 8px; - border-radius: 6px; white-space: nowrap; -} -.auto-import-badge-completed { background: rgba(74,222,128,0.1); color: #4ade80; } -.auto-import-badge-review { background: rgba(251,191,36,0.1); color: #fbbf24; } -.auto-import-badge-failed { background: rgba(248,113,113,0.1); color: #f87171; } -.auto-import-badge-neutral { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.4); } -.auto-import-badge-processing { - background: rgba(96,165,250,0.12); - color: #60a5fa; - animation: auto-import-badge-pulse 1.6s ease-in-out infinite; -} -@keyframes auto-import-badge-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } -} - -.auto-import-track-row-active { - background: rgba(96,165,250,0.08); - border-left: 2px solid #60a5fa; - padding-left: 6px; - margin-left: -8px; - border-radius: 3px; -} -.auto-import-track-row-active .auto-import-track-name { color: #60a5fa; font-weight: 600; } -.auto-import-track-row-done .auto-import-track-name, -.auto-import-track-row-done .auto-import-track-file { opacity: 0.4; } - -.auto-import-actions { - display: flex; gap: 4px; margin-top: 4px; -} - -.auto-import-actions button { font-size: 10px; padding: 3px 10px; } - -@media (max-width: 768px) { - .auto-import-card { flex-direction: column; align-items: flex-start; } - .auto-import-card-right { flex-direction: row; width: 100%; justify-content: space-between; } -} - /* ── Legacy (hidden) ── */ #wishlist-page-categories { display: none; margin-bottom: 24px; From 3fdc81579439fbac1972f55690bcc2be3627bbdc Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 18:08:05 +0300 Subject: [PATCH 05/32] fix(webui): show import refresh timestamp --- .../routes/import/-ui/import-page.module.css | 10 +++++ webui/src/routes/import/-ui/import-page.tsx | 37 ++++++++++++++++--- webui/src/routes/import/-ui/import-shared.tsx | 4 +- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index db15a907..209d6d52 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -63,6 +63,11 @@ color: #fff; } +.importPageRefreshBtnRefreshing { + opacity: 0.72; + cursor: progress; +} + .importPageStagingBar { display: flex; align-items: center; @@ -88,6 +93,11 @@ white-space: nowrap; } +.importStagingRefreshAt { + white-space: nowrap; + color: rgba(255, 255, 255, 0.4); +} + /* Tab Bar */ .importPageTabBar { display: flex; diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx index b92bb8a8..9180903e 100644 --- a/webui/src/routes/import/-ui/import-page.tsx +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -1,9 +1,9 @@ import { Link, Outlet } from '@tanstack/react-router'; +import { Show } from '@/components/primitives'; import { useReactPageShell } from '@/platform/shell/route-controllers'; import type { ImportQueueEntry } from '../-import.types'; -import styles from './import-page.module.css'; import { getQueueProgressPercent, @@ -11,12 +11,16 @@ import { getStagingStatsText, } from '../-import.helpers'; import { useImportQueueWorkflow } from '../-import.store'; +import styles from './import-page.module.css'; import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared'; export function ImportPage() { useReactPageShell('import'); const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging(); + const isRefreshing = stagingQuery.isRefetching; + const lastRefreshedAt = + stagingQuery.dataUpdatedAt > 0 ? formatShortTime(stagingQuery.dataUpdatedAt) : null; return (
@@ -26,6 +30,8 @@ export function ImportPage() { fileCountText={getStagingStatsText(stagingFiles)} loading={stagingQuery.isLoading} stagingPath={stagingPath} + refreshing={isRefreshing} + lastRefreshedAt={lastRefreshedAt} onRefresh={refreshStaging} /> @@ -38,17 +44,29 @@ export function ImportPage() { ); } +function formatShortTime(timestamp: number) { + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + function ImportHeader({ error, fileCountText, loading, stagingPath, + refreshing, + lastRefreshedAt, onRefresh, }: { error: unknown; fileCountText: string; loading: boolean; stagingPath: string; + refreshing: boolean; + lastRefreshedAt: string | null; onRefresh: () => void; }) { return ( @@ -60,18 +78,27 @@ function ImportHeader({
{error ? 'Import folder: error' : `Import: ${stagingPath}`} + + + {lastRefreshedAt ? `Last refreshed: ${lastRefreshedAt}` : null} + + {loading ? 'loading...' : fileCountText} @@ -129,9 +156,7 @@ function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) { onError={fallbackImage} /> ) : ( -
- A -
+
A
)}
{entry.label}
diff --git a/webui/src/routes/import/-ui/import-shared.tsx b/webui/src/routes/import/-ui/import-shared.tsx index b025f2a4..dfa5bdda 100644 --- a/webui/src/routes/import/-ui/import-shared.tsx +++ b/webui/src/routes/import/-ui/import-shared.tsx @@ -19,9 +19,9 @@ export function useImportStaging() { }); return { - refreshStaging: () => { + refreshStaging: async () => { clearFinishedJobs(); - void invalidateImportStagingQueries(queryClient); + await invalidateImportStagingQueries(queryClient); }, stagingFiles: stagingQuery.data?.files ?? [], stagingPath: stagingQuery.data?.staging_path || 'Not configured', From fe8ae8f2900f8b212f715ae9621f1b9fd8638568 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 18:32:04 +0300 Subject: [PATCH 06/32] fix(webui): restore import glyphs - bring the React import page back in line with the legacy emoji/glyph treatment\n- restore album, singles, auto-import, and queue fallback icons\n- keep the visual refresh aligned with the old page while preserving the React port --- webui/src/routes/import/-import.helpers.ts | 18 +++++++++--------- .../src/routes/import/-ui/album-import-tab.tsx | 6 +++--- .../src/routes/import/-ui/auto-import-tab.tsx | 2 +- .../routes/import/-ui/import-page.module.css | 1 + webui/src/routes/import/-ui/import-page.tsx | 2 +- .../routes/import/-ui/singles-import-tab.tsx | 4 ++-- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/webui/src/routes/import/-import.helpers.ts b/webui/src/routes/import/-import.helpers.ts index 0805b5d9..d6f8d288 100644 --- a/webui/src/routes/import/-import.helpers.ts +++ b/webui/src/routes/import/-import.helpers.ts @@ -235,15 +235,15 @@ export function getAutoImportStatusMeta(status: string): { }; const icons: Record = { - completed: 'OK', - pending_review: '!', - needs_identification: 'x', - failed: 'x', - scanning: '~', - matched: 'OK', - rejected: 'x', - approved: 'OK', - processing: '~', + completed: '✓', + pending_review: '⚠', + needs_identification: '✗', + failed: '✗', + scanning: '⌛', + matched: '✓', + rejected: '✕', + approved: '✓', + processing: '⧗', }; return { diff --git a/webui/src/routes/import/-ui/album-import-tab.tsx b/webui/src/routes/import/-ui/album-import-tab.tsx index aa07185b..530fb59c 100644 --- a/webui/src/routes/import/-ui/album-import-tab.tsx +++ b/webui/src/routes/import/-ui/album-import-tab.tsx @@ -256,7 +256,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode {group.album}
- {group.artist} - {group.file_count} tracks + {group.artist} · {group.file_count} tracks
@@ -369,7 +369,7 @@ function AlbumCard({ {album.artist}
- {album.total_tracks || 0} tracks - {album.release_date?.substring(0, 4) || ''} + {album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
); @@ -422,7 +422,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
{albumMatch.album.name}
{albumMatch.album.artist}
- {albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks -{' '} + {albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks ·{' '} {albumMatch.album.release_date?.substring(0, 4) || ''}
diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx index c8ac1075..62b94c84 100644 --- a/webui/src/routes/import/-ui/auto-import-tab.tsx +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -417,7 +417,7 @@ function AutoImportResultCard({ onError={fallbackImage} /> ) : ( -
Album
+
💿
)}
diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 209d6d52..eb49b5ae 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -115,6 +115,7 @@ color: rgba(255, 255, 255, 0.5); font-size: 14px; font-weight: 500; + text-decoration: none; cursor: pointer; transition: all 0.2s; margin-bottom: -1px; diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx index 9180903e..05a22599 100644 --- a/webui/src/routes/import/-ui/import-page.tsx +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -156,7 +156,7 @@ function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) { onError={fallbackImage} /> ) : ( -
A
+
)}
{entry.label}
diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index 2c3f5a53..b6c3c40d 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -214,7 +214,7 @@ export function SinglesImportPanel({
{manualMatch ? (
- OK {manualMatch.name} - {manualMatch.artist} + ✓ {manualMatch.name} - {manualMatch.artist}
{openSearchIndex === index ? ( From 89252cf6e4c28c6f6af94d484ee650cf733bb642 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 18:54:08 +0300 Subject: [PATCH 07/32] fix(webui): refine import singles checkbox - switch the singles selector to a real checkbox input for cleaner semantics\n- keep the mobile import page layout aligned while avoiding legacy button sizing\n- tune the checkbox tick so it stays visually centered and readable --- .../routes/import/-ui/import-page.module.css | 81 ++++++++++++++++--- .../routes/import/-ui/singles-import-tab.tsx | 18 +++-- 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index eb49b5ae..b53652d5 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -628,9 +628,26 @@ border-color: rgba(var(--accent-light-rgb), 0.2); } +.importPageSingleCheckboxWrap { + grid-area: checkbox; + position: relative; + width: 18px; + height: 18px; + flex-shrink: 0; + margin-top: 2px; +} + +.importPageSingleCheckboxInput { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; +} + .importPageSingleCheckbox { width: 18px; height: 18px; + box-sizing: border-box; border: 2px solid rgba(255, 255, 255, 0.2); background: transparent; padding: 0; @@ -643,16 +660,28 @@ flex-shrink: 0; } -.importPageSingleCheckbox.checked { - background: rgb(var(--accent-light-rgb)); - border-color: rgb(var(--accent-light-rgb)); -} - -.importPageSingleCheckbox.checked::after { +.importPageSingleCheckbox::after { content: '✓'; color: #000; font-size: 12px; font-weight: 700; + line-height: 1; + opacity: 0; + transform: translateY(-0.5px) scaleX(1.18); +} + +.importPageSingleCheckboxInput:focus-visible + .importPageSingleCheckbox { + outline: none; + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); +} + +.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox { + background: rgb(var(--accent-light-rgb)); + border-color: rgb(var(--accent-light-rgb)); +} + +.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox::after { + opacity: 1; } .importPageSingleInfo { @@ -1010,17 +1039,29 @@ } .importPageMatchRow { - grid-template-columns: 28px 1fr; - gap: 8px; + grid-template-columns: 28px minmax(0, 1fr) auto; + grid-template-areas: + "num track track" + "num file unmatch"; + gap: 6px 8px; + align-items: center; + } + + .importPageMatchNum { + grid-area: num; + } + + .importPageMatchTrack { + grid-area: track; } .importPageMatchFile { - grid-column: 1 / -1; - padding-left: 28px; + grid-area: file; + min-width: 0; } .importPageMatchUnmatch { - grid-column: 1 / -1; + grid-area: unmatch; justify-self: end; } @@ -1035,12 +1076,26 @@ } .importPageSingleItem { - grid-template-columns: 28px 1fr; + grid-template-columns: 28px minmax(0, 1fr) auto; + grid-template-areas: + "checkbox info actions" + "search search search"; + align-items: start; + gap: 8px; + } + + .importPageSingleInfo { + grid-area: info; } .importPageSingleActions { - grid-column: 1 / -1; + grid-area: actions; justify-self: end; + align-self: start; + } + + .importPageSingleSearchPanel { + grid-area: search; } .importPageSingleSearchPanel { diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index b6c3c40d..2b7133b9 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -197,14 +197,16 @@ export function SinglesImportPanel({ }`} data-single-idx={index} > -
-
+
{files.length === 0 ? (
No audio files found in import folder
) : ( - files.map((file, index) => { - const manualMatch = manualMatches[index]; - const isSelected = selected.has(index); - const searchState = searchStates[index]; + files.map((file) => { + const fileKey = getStagingFileKey(file); + const manualMatch = manualMatches[fileKey]; + const isSelected = selected.has(fileKey); + const searchState = searchStates[fileKey]; return (
@@ -220,7 +228,7 @@ export function SinglesImportPanel({ @@ -231,14 +239,14 @@ export function SinglesImportPanel({
- {openSearchIndex === index ? ( + {openSearchKey === fileKey ? ( void; - onRunSearch: (index: number, query: string) => void; - onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void; + onQueryChange: (fileKey: string, query: string) => void; + onRunSearch: (fileKey: string, query: string) => void; + onSelectMatch: (fileKey: string, track: ImportTrackResult) => void; }) { const query = searchState?.query ?? ''; @@ -277,20 +285,20 @@ function SingleSearchPanel({ className={styles.importPageSingleSearchInput} value={query} placeholder="Search artist - title..." - onChange={(event) => onQueryChange(fileIndex, event.target.value)} + onChange={(event) => onQueryChange(fileKey, event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter') onRunSearch(fileIndex, query); + if (event.key === 'Enter') onRunSearch(fileKey, query); }} />
-
+
{searchState?.loading ? (
Searching...
) : searchState?.error ? ( @@ -303,7 +311,7 @@ function SingleSearchPanel({ key={`${track.source || 'source'}-${track.id}-${index}`} type="button" className={styles.importPageSingleResultItem} - onClick={() => onSelectMatch(fileIndex, track)} + onClick={() => onSelectMatch(fileKey, track)} > {track.image_url ? ( Date: Sat, 16 May 2026 19:58:36 +0300 Subject: [PATCH 09/32] refactor(webui): share import form primitives - add shared Base UI-backed checkbox and slider primitives under the form component layer - move the singles import checkbox and auto-import confidence slider to the shared controls - keep the import route tests aligned with the new accessible component roles --- webui/src/components/form/form.module.css | 114 ++++++++++++++++++ webui/src/components/form/form.test.tsx | 28 +++++ webui/src/components/form/form.tsx | 81 +++++++++++++ webui/src/routes/import/-route.test.tsx | 8 +- .../routes/import/-ui/album-import-tab.tsx | 28 +++-- .../src/routes/import/-ui/auto-import-tab.tsx | 63 +++++----- .../routes/import/-ui/import-page.module.css | 73 ++--------- webui/src/routes/import/-ui/import-page.tsx | 9 +- .../routes/import/-ui/singles-import-tab.tsx | 25 ++-- 9 files changed, 298 insertions(+), 131 deletions(-) diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css index bf673cac..54638847 100644 --- a/webui/src/components/form/form.module.css +++ b/webui/src/components/form/form.module.css @@ -129,6 +129,120 @@ color: #fff; } +.checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + box-sizing: border-box; + margin: 0; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + color: #000; + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; + color-scheme: dark; +} + +.checkbox[data-checked] { + border-color: rgb(var(--accent-light-rgb)); + background: rgb(var(--accent-light-rgb)); +} + +.checkbox[data-focused] { + outline: none; + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); +} + +.checkboxIndicator { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 0.18s ease; +} + +.checkbox[data-checked] .checkboxIndicator { + opacity: 1; +} + +.checkboxIcon { + color: #000; + font-size: 12px; + font-weight: 700; + line-height: 1; + transform: translateY(-0.5px) scaleX(1.18); +} + +.rangeRoot { + display: inline-flex; + flex: 0 0 160px; + width: 160px; + min-width: 160px; + color-scheme: dark; +} + +.rangeControl { + display: flex; + align-items: center; + width: 100%; + min-height: 28px; + cursor: pointer; +} + +.rangeTrack { + position: relative; + width: 100%; + height: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04); +} + +.rangeIndicator { + height: 100%; + border-radius: inherit; + background: linear-gradient( + 90deg, + rgba(var(--accent-rgb), 0.95), + rgba(var(--accent-light-rgb), 0.95) + ); +} + +.rangeThumb { + width: 16px; + height: 16px; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 50%; + background: + radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%), + rgb(var(--accent-light-rgb)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); + transition: + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.rangeThumb:hover { + transform: scale(1.04); +} + +.rangeThumb:focus-visible { + box-shadow: + 0 0 0 4px rgba(var(--accent-light-rgb), 0.14), + 0 2px 8px rgba(0, 0, 0, 0.28); +} + .optionCardGroup { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx index 3488a2f6..2feb7a2e 100644 --- a/webui/src/components/form/form.test.tsx +++ b/webui/src/components/form/form.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { Button, + Checkbox, FormActions, FormError, FormField, @@ -11,6 +12,7 @@ import { OptionButtonGroup, OptionCard, OptionCardGroup, + RangeInput, Select, TextArea, TextInput, @@ -22,6 +24,8 @@ function FormDemo() { const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover'); const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal'); const [status, setStatus] = useState('open'); + const [archive, setArchive] = useState(false); + const [confidence, setConfidence] = useState(90); return (
@@ -90,6 +94,20 @@ function FormDemo() { + + + + + + + + @@ -112,6 +130,16 @@ describe('form primitives', () => { fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } }); expect(screen.getByLabelText('Status')).toHaveValue('resolved'); + const archiveCheckbox = screen.getByRole('checkbox', { name: 'Archive' }); + expect(archiveCheckbox).not.toBeChecked(); + fireEvent.click(archiveCheckbox); + expect(archiveCheckbox).toBeChecked(); + + const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' }); + expect(confidenceSlider).toHaveValue('90'); + fireEvent.change(confidenceSlider, { target: { value: '75' } }); + expect(confidenceSlider).toHaveValue('75'); + const wrongCover = screen.getByRole('button', { name: /wrong cover/i }); const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i }); expect(wrongCover).toHaveAttribute('aria-pressed', 'true'); diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx index 15938435..1a26870e 100644 --- a/webui/src/components/form/form.tsx +++ b/webui/src/components/form/form.tsx @@ -1,10 +1,13 @@ import { Button as BaseButton } from '@base-ui/react/button'; +import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'; import { Field } from '@base-ui/react/field'; import { Input as BaseInput } from '@base-ui/react/input'; +import { Slider } from '@base-ui/react/slider'; import { Toggle as BaseToggle } from '@base-ui/react/toggle'; import clsx from 'clsx'; import { forwardRef, + type CSSProperties, type ComponentPropsWithoutRef, type ButtonHTMLAttributes, type SelectHTMLAttributes, @@ -82,6 +85,84 @@ export const Select = forwardRef(function Select return - - +
@@ -431,20 +433,20 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {

Track Matching

- - +
@@ -504,7 +506,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) { {file ? ( - + ) : null}
@@ -564,7 +566,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
- +
) : ( diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx index 62b94c84..471c10f3 100644 --- a/webui/src/routes/import/-ui/auto-import-tab.tsx +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -1,6 +1,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; +import { Button, RangeInput, Select } from '@/components/form/form'; + import type { ImportAutoFilter, ImportAutoImportResult, @@ -171,7 +173,7 @@ export function AutoImportPanel({ {getAutoImportStatusText(statusQuery.data)} {statusQuery.data?.running ? ( - + ) : null}
{statusQuery.data?.running ? (
- - -
+ + ) : null} {activeLines.length > 0 ? ( @@ -264,7 +267,7 @@ export function AutoImportPanel({
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( - + ))}
{counts.review > 0 ? ( - + ) : null} {counts.imported + counts.failed > 0 ? ( - + ) : null}
@@ -445,7 +448,7 @@ function AutoImportResultCard({
{confidencePercent}% confidence
{result.status === 'pending_review' ? (
- - +
) : null}
diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 1ac434ef..cf99b973 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -637,53 +637,6 @@ margin-top: 2px; } -.importPageSingleCheckboxInput { - position: absolute; - inset: 0; - opacity: 0; - margin: 0; -} - -.importPageSingleCheckbox { - width: 18px; - height: 18px; - box-sizing: border-box; - border: 2px solid rgba(255, 255, 255, 0.2); - background: transparent; - padding: 0; - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - flex-shrink: 0; -} - -.importPageSingleCheckbox::after { - content: '✓'; - color: #000; - font-size: 12px; - font-weight: 700; - line-height: 1; - opacity: 0; - transform: translateY(-0.5px) scaleX(1.18); -} - -.importPageSingleCheckboxInput:focus-visible + .importPageSingleCheckbox { - outline: none; - box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); -} - -.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox { - background: rgb(var(--accent-light-rgb)); - border-color: rgb(var(--accent-light-rgb)); -} - -.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox::after { - opacity: 1; -} - .importPageSingleInfo { grid-column: 2; min-width: 0; @@ -1199,23 +1152,19 @@ color: rgba(255, 255, 255, 0.5); } -.autoImportSettingsRow label { +.autoImportSetting { display: flex; align-items: center; gap: 6px; } -.autoImportSettingsRow input[type='range'] { - width: 100px; -} -.autoImportSettingsRow select { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - color: #fff; - border-radius: 6px; - padding: 3px 8px; - font-size: 12px; -} +.autoImportConfidenceValue { + display: inline-block; + flex: 0 0 5ch; + width: 5ch; + text-align: right; + font-variant-numeric: tabular-nums; +} .autoImportEmpty { text-align: center; padding: 40px 20px; @@ -1468,12 +1417,6 @@ display: inline-flex; align-items: center; gap: 7px; - padding: 9px 18px; - border-radius: 10px; - border: 1px solid transparent; - cursor: pointer; - font-size: 13px; - font-weight: 500; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); white-space: nowrap; letter-spacing: 0.1px; diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx index 05a22599..932d4922 100644 --- a/webui/src/routes/import/-ui/import-page.tsx +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -1,6 +1,7 @@ import { Link, Outlet } from '@tanstack/react-router'; import { Show } from '@/components/primitives'; +import { Button } from '@/components/form/form'; import { useReactPageShell } from '@/platform/shell/route-controllers'; import type { ImportQueueEntry } from '../-import.types'; @@ -76,7 +77,7 @@ function ImportHeader({ Import Music - +
@@ -118,7 +119,7 @@ function ImportProcessingQueue() { >
Processing - +
{queue.map((entry) => ( diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index 63885b36..a9c05a8b 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -1,5 +1,7 @@ import { useEffect } from 'react'; +import { Button, Checkbox, TextInput } from '@/components/form/form'; + import type { SingleSearchState } from '../-import.store'; import type { ImportTrackResult } from '../-import.types'; import type { ImportStagingFile } from '../-import.types'; @@ -172,12 +174,12 @@ export function SinglesImportPanel({ <>
- - +
@@ -206,14 +208,11 @@ export function SinglesImportPanel({ data-single-key={fileKey} >
{file.filename}
@@ -280,7 +279,7 @@ function SingleSearchPanel({ return (
- - +
{searchState?.loading ? ( From af3d51c2ed110ab874c052b34452d413f07f8e8a Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 20:42:05 +0300 Subject: [PATCH 10/32] refactor(webui): theme import buttons - add shared button variants and size controls backed by theme-aware styles - move album import search controls onto shared button styling - keep the import route layout-specific CSS limited to positioning --- webui/src/components/form/form.module.css | 76 ++++++++++++++++++- webui/src/components/form/form.test.tsx | 9 ++- webui/src/components/form/form.tsx | 26 ++++++- .../routes/import/-ui/album-import-tab.tsx | 11 +-- .../routes/import/-ui/import-page.module.css | 68 +---------------- .../routes/import/-ui/singles-import-tab.tsx | 2 +- 6 files changed, 115 insertions(+), 77 deletions(-) diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css index 54638847..2da07111 100644 --- a/webui/src/components/form/form.module.css +++ b/webui/src/components/form/form.module.css @@ -363,11 +363,8 @@ color: #fff; cursor: pointer; font: inherit; - font-size: 13px; font-weight: 600; line-height: 1.2; - min-height: 36px; - padding: 8px 12px; transition: transform 0.18s ease, border-color 0.18s ease, @@ -376,6 +373,34 @@ color 0.18s ease; } +.buttonSizeSm { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; +} + +.buttonSizeMd { + min-height: 36px; + padding: 8px 12px; + font-size: 13px; +} + +.buttonSizeLg { + min-height: 40px; + padding: 10px 20px; + font-size: 14px; +} + +.buttonIcon { + width: var(--button-icon-size, 36px); + height: var(--button-icon-size, 36px); + min-height: var(--button-icon-size, 36px); + min-width: var(--button-icon-size, 36px); + padding: 0; + font-size: var(--button-icon-font-size, 15px); + line-height: 1; +} + .button:hover:not(:disabled) { transform: translateY(-1px); border-color: rgba(255, 255, 255, 0.18); @@ -394,6 +419,51 @@ transform: none; } +.buttonPrimary { + border-color: rgba(var(--accent-light-rgb), 0.45); + background: rgb(var(--accent-light-rgb)); + color: #000; +} + +.buttonPrimary:hover:not(:disabled) { + border-color: rgba(var(--accent-light-rgb), 0.55); + background: rgba(var(--accent-light-rgb), 0.95); + color: #000; +} + +.buttonPrimary:focus-visible { + border-color: rgba(var(--accent-light-rgb), 0.8); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.16); +} + +.buttonPrimary:disabled { + opacity: 0.62; + background: rgba(var(--accent-light-rgb), 0.45); + color: rgba(0, 0, 0, 0.55); +} + +.buttonGhost { + border-color: transparent; + background: transparent; + color: rgba(255, 255, 255, 0.55); +} + +.buttonGhost:hover:not(:disabled) { + transform: none; + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.07); + color: #fff; +} + +.buttonGhost:focus-visible { + border-color: rgba(var(--accent-light-rgb), 0.42); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); +} + +.buttonGhost:disabled { + opacity: 0.5; +} + .formError { color: #ff8d8d; font-size: 12px; diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx index 2feb7a2e..21a2cfa3 100644 --- a/webui/src/components/form/form.test.tsx +++ b/webui/src/components/form/form.test.tsx @@ -112,7 +112,9 @@ function FormDemo() { - + ); @@ -154,6 +156,9 @@ describe('form primitives', () => { expect(highPriority).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute( + 'data-variant', + 'primary', + ); }); }); diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx index 1a26870e..6d6b04fa 100644 --- a/webui/src/components/form/form.tsx +++ b/webui/src/components/form/form.tsx @@ -247,13 +247,35 @@ type BaseButtonProps = ComponentPropsWithoutRef; export type ButtonProps = Omit & { className?: string; + size?: 'sm' | 'md' | 'lg' | 'icon'; + variant?: 'default' | 'primary' | 'ghost'; }; export const Button = forwardRef(function Button( - { className, type = 'button', ...props }, + { className, size = 'md', type = 'button', variant = 'default', ...props }, ref, ) { - return ; + return ( + + ); }); export function FormError({ className, message }: { className?: string; message?: ReactNode }) { diff --git a/webui/src/routes/import/-ui/album-import-tab.tsx b/webui/src/routes/import/-ui/album-import-tab.tsx index 82d3fed3..aae1089d 100644 --- a/webui/src/routes/import/-ui/album-import-tab.tsx +++ b/webui/src/routes/import/-ui/album-import-tab.tsx @@ -298,18 +298,19 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode if (event.key === 'Enter') onRunSearch(); }} /> - +
@@ -568,7 +569,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
-
+ {(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( - + ))}
{counts.review > 0 ? ( ) : null} -
+
) : null} @@ -449,7 +450,8 @@ function AutoImportResultCard({
- +
{openSearchKey === fileKey ? (
@@ -203,9 +205,7 @@ export function SinglesImportPanel({ return (