Merge pull request #686 from kettui/feat/react-migration-import
feat(webui): migrate import page to React
This commit is contained in:
commit
a1222d5a8f
45 changed files with 6767 additions and 3389 deletions
|
|
@ -56,6 +56,12 @@ def search_kind(client, query: str, kind: str, source_name: Optional[str] = None
|
||||||
"release_date": album.release_date,
|
"release_date": album.release_date,
|
||||||
"total_tracks": album.total_tracks,
|
"total_tracks": album.total_tracks,
|
||||||
"album_type": album.album_type,
|
"album_type": album.album_type,
|
||||||
|
"format": getattr(album, "format", None),
|
||||||
|
"country": getattr(album, "country", None),
|
||||||
|
"status": getattr(album, "status", None),
|
||||||
|
"label": getattr(album, "label", None),
|
||||||
|
"disambiguation": getattr(album, "disambiguation", None),
|
||||||
|
"release_group_id": getattr(album, "release_group_id", None),
|
||||||
"external_urls": album.external_urls or {},
|
"external_urls": album.external_urls or {},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ class _Artist:
|
||||||
|
|
||||||
class _Album:
|
class _Album:
|
||||||
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
|
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
|
||||||
total_tracks=10, album_type='album', external_urls=None):
|
total_tracks=10, album_type='album', external_urls=None, format=None,
|
||||||
|
country=None, status=None, label=None, disambiguation=None,
|
||||||
|
release_group_id=None):
|
||||||
self.id = id_
|
self.id = id_
|
||||||
self.name = name
|
self.name = name
|
||||||
self.artists = artists or []
|
self.artists = artists or []
|
||||||
|
|
@ -28,6 +30,12 @@ class _Album:
|
||||||
self.total_tracks = total_tracks
|
self.total_tracks = total_tracks
|
||||||
self.album_type = album_type
|
self.album_type = album_type
|
||||||
self.external_urls = external_urls
|
self.external_urls = external_urls
|
||||||
|
self.format = format
|
||||||
|
self.country = country
|
||||||
|
self.status = status
|
||||||
|
self.label = label
|
||||||
|
self.disambiguation = disambiguation
|
||||||
|
self.release_group_id = release_group_id
|
||||||
|
|
||||||
|
|
||||||
class _Track:
|
class _Track:
|
||||||
|
|
@ -99,6 +107,27 @@ def test_search_kind_albums_handles_no_artists():
|
||||||
assert result[0]['artist'] == 'Unknown Artist'
|
assert result[0]['artist'] == 'Unknown Artist'
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_kind_albums_passthrough_release_metadata():
|
||||||
|
client = _Client(albums=[_Album(
|
||||||
|
'a1',
|
||||||
|
'Variant',
|
||||||
|
artists=['Artist'],
|
||||||
|
format='CD',
|
||||||
|
country='US',
|
||||||
|
status='Official',
|
||||||
|
label='Fixture Records',
|
||||||
|
disambiguation='clean',
|
||||||
|
release_group_id='rg-1',
|
||||||
|
)])
|
||||||
|
result = sources.search_kind(client, 'v', 'albums')
|
||||||
|
assert result[0]['format'] == 'CD'
|
||||||
|
assert result[0]['country'] == 'US'
|
||||||
|
assert result[0]['status'] == 'Official'
|
||||||
|
assert result[0]['label'] == 'Fixture Records'
|
||||||
|
assert result[0]['disambiguation'] == 'clean'
|
||||||
|
assert result[0]['release_group_id'] == 'rg-1'
|
||||||
|
|
||||||
|
|
||||||
def test_search_kind_tracks_returns_full_shape():
|
def test_search_kind_tracks_returns_full_shape():
|
||||||
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
|
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
|
||||||
duration_ms=383000, image_url='m.jpg',
|
duration_ms=383000, image_url='m.jpg',
|
||||||
|
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
"""Pin the import-page album-lookup cache pattern in
|
|
||||||
``webui/static/stats-automations.js`` — github issue #524 regression
|
|
||||||
guard at the source-text level.
|
|
||||||
|
|
||||||
Why a structural test instead of a behavioral JS test:
|
|
||||||
|
|
||||||
``stats-automations.js`` is a ~7k-line file with a lot of global state
|
|
||||||
+ inline DOM rendering. Loading it into a sandboxed Node `vm` context
|
|
||||||
(the pattern used in `tests/static/test_discover_section_controller.mjs`)
|
|
||||||
would require stubbing dozens of unrelated dependencies. The file
|
|
||||||
needs to be modularized before behavioral tests are practical for
|
|
||||||
arbitrary functions in it.
|
|
||||||
|
|
||||||
Until then, this test fails the suite if the critical pattern from
|
|
||||||
the #524 fix gets removed:
|
|
||||||
|
|
||||||
1. The album cache (``_albumLookup`` field on ``importPageState``)
|
|
||||||
2. Card renderers populating the cache before emitting the onclick
|
|
||||||
3. The match-POST builder reading source/name/artist from the cache
|
|
||||||
|
|
||||||
If anyone deletes the cache, the click handler, or the cache writes,
|
|
||||||
this test catches it before the regression ships.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
_SOURCE = _REPO_ROOT / "webui" / "static" / "stats-automations.js"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def js_source() -> str:
|
|
||||||
return _SOURCE.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_album_lookup_cache_field_exists_on_state(js_source: str):
|
|
||||||
"""importPageState must have an `_albumLookup` field. Without it,
|
|
||||||
card renderers have nowhere to stash source/name/artist for the
|
|
||||||
click handler to read."""
|
|
||||||
assert "_albumLookup:" in js_source, (
|
|
||||||
"importPageState._albumLookup field missing — the album cache "
|
|
||||||
"that backs the source-routing fix for issue #524 has been "
|
|
||||||
"removed. The click handler will fall back to passing only "
|
|
||||||
"album_id and the backend will silently misroute lookups again."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_select_album_handler_reads_cache(js_source: str):
|
|
||||||
"""importPageSelectAlbum must read source / name / artist from
|
|
||||||
the cache and include them in the match POST body. The whole
|
|
||||||
point of the fix."""
|
|
||||||
# Find the function body
|
|
||||||
match = re.search(
|
|
||||||
r"async function importPageSelectAlbum\([^)]*\) \{(.*?)^\}",
|
|
||||||
js_source, re.DOTALL | re.MULTILINE,
|
|
||||||
)
|
|
||||||
assert match, "importPageSelectAlbum function not found"
|
|
||||||
body = match.group(1)
|
|
||||||
|
|
||||||
# Must read from the lookup cache
|
|
||||||
assert "_albumLookup[" in body, (
|
|
||||||
"importPageSelectAlbum no longer reads from "
|
|
||||||
"importPageState._albumLookup — match POST will drop source "
|
|
||||||
"again, see issue #524."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Must build a matchBody that includes source + album_name + album_artist
|
|
||||||
for required_field in ("source:", "album_name:", "album_artist:"):
|
|
||||||
assert required_field in body, (
|
|
||||||
f"matchBody missing required field {required_field!r}. "
|
|
||||||
"Backend's get_artist_album_tracks needs source to route "
|
|
||||||
"the lookup to the correct metadata client. Without it, "
|
|
||||||
"cross-source album_ids fall through to the failure-fallback "
|
|
||||||
"dict (Unknown Artist / album_id-as-title / 0 tracks). "
|
|
||||||
"See issue #524 for the original symptom."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_card_renderer_populates_cache_before_onclick(js_source: str):
|
|
||||||
"""The shared card-renderer ``_renderSuggestionCard`` must write to
|
|
||||||
``_albumLookup`` before emitting the onclick — otherwise the click
|
|
||||||
handler reads an empty cache for newly-displayed albums.
|
|
||||||
|
|
||||||
Originally this test required >=2 cache writes (one per inline
|
|
||||||
renderer), but the search-results inline render was consolidated
|
|
||||||
into a single ``_renderSuggestionCard`` call as part of the #681
|
|
||||||
fix. The invariant now is: the shared renderer populates the cache,
|
|
||||||
and every render call site goes through it (no inline duplicates)."""
|
|
||||||
# 1. The shared renderer must contain the cache write.
|
|
||||||
match = re.search(
|
|
||||||
r"function _renderSuggestionCard\([^)]*\) \{(.*?)^\}",
|
|
||||||
js_source, re.DOTALL | re.MULTILINE,
|
|
||||||
)
|
|
||||||
assert match, "_renderSuggestionCard function not found"
|
|
||||||
body = match.group(1)
|
|
||||||
assert re.search(r"_albumLookup\[a\.id\]\s*=\s*\{", body), (
|
|
||||||
"_renderSuggestionCard no longer writes to _albumLookup before "
|
|
||||||
"emitting the onclick — every card rendered through this helper "
|
|
||||||
"would have an empty cache on click, regressing issue #524."
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. No inline card render allowed outside the shared helper.
|
|
||||||
# A second `_albumLookup[a.id] = {` write means a caller is
|
|
||||||
# re-implementing the renderer instead of calling the helper —
|
|
||||||
# that's exactly the duplication the #524 fix consolidated away.
|
|
||||||
cache_writes = re.findall(r"_albumLookup\[a\.id\]\s*=\s*\{", js_source)
|
|
||||||
assert len(cache_writes) == 1, (
|
|
||||||
f"Expected exactly 1 _albumLookup write (inside _renderSuggestionCard), "
|
|
||||||
f"found {len(cache_writes)}. A new inline card-render site has "
|
|
||||||
"duplicated the cache-write logic — route the new caller through "
|
|
||||||
"_renderSuggestionCard(a, primarySource) instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cache_entry_carries_source_field(js_source: str):
|
|
||||||
"""The cache must store `source:` per entry — not just id/name/artist."""
|
|
||||||
write_blocks = re.findall(
|
|
||||||
r"_albumLookup\[a\.id\]\s*=\s*\{[^}]*\}",
|
|
||||||
js_source,
|
|
||||||
)
|
|
||||||
assert write_blocks, "no _albumLookup writes found"
|
|
||||||
assert any("source:" in block for block in write_blocks), (
|
|
||||||
"_albumLookup cache entries must include `source` — that's the "
|
|
||||||
"field the click handler forwards to /api/import/album/match "
|
|
||||||
"to route the lookup to the correct provider."
|
|
||||||
)
|
|
||||||
|
|
@ -16,6 +16,9 @@ This folder is the home for React migration planning work inside `webui`.
|
||||||
- cross-route risk assessment
|
- cross-route risk assessment
|
||||||
- [stats-migration-plan.md](./stats-migration-plan.md)
|
- [stats-migration-plan.md](./stats-migration-plan.md)
|
||||||
- route-specific migration plan for `stats`
|
- 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
|
## Naming Guidance
|
||||||
|
|
||||||
|
|
|
||||||
350
webui/docs/migration/import-migration-plan.md
Normal file
350
webui/docs/migration/import-migration-plan.md
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
# WebUI Import Migration Plan
|
||||||
|
|
||||||
|
Snapshot date: 2026-05-24
|
||||||
|
|
||||||
|
## 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 subtree now owns import rendering, nested route state, album matching, singles matching, auto-import controls, and the client-side processing queue.
|
||||||
|
- The old `tab=` URL contract has been replaced by `/import/album`, `/import/singles`, and `/import/auto`, with `/import` redirecting to `/import/album`.
|
||||||
|
- Route-local workflow state lives in `webui/src/routes/import/-import.store.ts`, which keeps draft matching, selection, and queue state alive while navigating within the import route.
|
||||||
|
- The old import-page-specific functions have already been removed from `webui/static/stats-automations.js`; any remaining `import` references there belong to the broader automation feature set, not this page migration.
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
## Implemented Route Slice
|
||||||
|
|
||||||
|
```text
|
||||||
|
webui/src/routes/import/
|
||||||
|
route.tsx
|
||||||
|
index.tsx
|
||||||
|
album.tsx
|
||||||
|
auto.tsx
|
||||||
|
singles.tsx
|
||||||
|
-import.types.ts
|
||||||
|
-import.api.ts
|
||||||
|
-import.helpers.ts
|
||||||
|
-import.store.ts
|
||||||
|
-route.test.tsx
|
||||||
|
-ui/
|
||||||
|
import-page.tsx
|
||||||
|
album-import-tab.tsx
|
||||||
|
auto-import-tab.tsx
|
||||||
|
singles-import-tab.tsx
|
||||||
|
import-shared.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implemented Route Responsibilities
|
||||||
|
|
||||||
|
`route.tsx`
|
||||||
|
|
||||||
|
- declare `/import`
|
||||||
|
- gate route through `bridge.isPageAllowed('import')`
|
||||||
|
- preload shell context
|
||||||
|
- prefetch the staging-files query without blocking on transient fetch failure
|
||||||
|
|
||||||
|
`index.tsx`
|
||||||
|
|
||||||
|
- redirect `/import` to `/import/album`
|
||||||
|
|
||||||
|
`album.tsx`
|
||||||
|
|
||||||
|
- prefetch album staging groups and suggestions
|
||||||
|
|
||||||
|
`auto.tsx`
|
||||||
|
|
||||||
|
- validate the `autoFilter` search param
|
||||||
|
- keep route-driven filter changes in the URL while leaving the rest of the workflow state local
|
||||||
|
|
||||||
|
`singles.tsx`
|
||||||
|
|
||||||
|
- mount the singles import tab without extra route-level loader work
|
||||||
|
|
||||||
|
`-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
|
||||||
|
- invalidation helpers for broad route refreshes, staging-only refreshes, and auto-import-only refreshes
|
||||||
|
|
||||||
|
`-import.helpers.ts`
|
||||||
|
|
||||||
|
- byte-size formatting
|
||||||
|
- album and track display labels
|
||||||
|
- confidence class/label mapping
|
||||||
|
- staging match normalization
|
||||||
|
- auto-import result filtering and counters
|
||||||
|
|
||||||
|
`-import.store.ts`
|
||||||
|
|
||||||
|
- album search state, selected album, auto-group file paths, and match overrides
|
||||||
|
- selected single-file state and manual matches
|
||||||
|
- queue job state and queue entry updates
|
||||||
|
- single-search draft state
|
||||||
|
- draft state survival across nested route remounts
|
||||||
|
|
||||||
|
`-ui/import-page.tsx`
|
||||||
|
|
||||||
|
- page chrome and nested route navigation
|
||||||
|
- queue summary and queue-item rendering
|
||||||
|
- nested route outlet for album, singles, and auto views
|
||||||
|
|
||||||
|
## Search Params
|
||||||
|
|
||||||
|
Use nested route paths for durable, shareable tab state:
|
||||||
|
|
||||||
|
- `/import/album`
|
||||||
|
- default landing route
|
||||||
|
- `/import/singles`
|
||||||
|
- `/import/auto`
|
||||||
|
- `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 choice belongs in the path, which keeps deep links simple and avoids an extra `tab=` query param.
|
||||||
|
- The auto-import filter is still 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()`
|
||||||
|
|
||||||
|
Nested-route 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.
|
||||||
|
|
||||||
|
The implemented route keeps the same overall migration shape, but the final URL contract uses nested route paths instead of a `tab=` search param.
|
||||||
|
|
||||||
|
## Testing Sketch
|
||||||
|
|
||||||
|
Unit tests:
|
||||||
|
|
||||||
|
- route path and filter 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 redirects to `/import/album`
|
||||||
|
- `/import/singles` renders the Singles tab
|
||||||
|
- `/import/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
|
||||||
|
- client workflow drafts survive page remounts
|
||||||
|
|
||||||
|
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 auto subroute 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 nested route paths plus a validated `autoFilter` search param.
|
||||||
|
- The route uses TanStack Query for staging data, suggestions, auto-import polling, mutations, and invalidation.
|
||||||
|
- Tests cover shell ownership, nested route 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
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# WebUI Page Migration Overview
|
# WebUI Page Migration Overview
|
||||||
|
|
||||||
Snapshot date: 2026-05-14
|
Snapshot date: 2026-05-15
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
- The shell route manifest now has 18 page ids.
|
- 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:
|
- Since the last snapshot, the biggest changes are:
|
||||||
- `downloads` was renamed into `search`.
|
- `downloads` was renamed into `search`.
|
||||||
- The live queue became `active-downloads`.
|
- The live queue became `active-downloads`.
|
||||||
|
|
@ -78,9 +78,9 @@ Rollups:
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed |
|
| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed |
|
||||||
| `stats` | 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 |
|
| `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 |
|
||||||
| `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 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 |
|
| `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 |
|
||||||
| `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
| `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
||||||
| `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
| `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
||||||
|
|
@ -130,11 +130,13 @@ Rollups:
|
||||||
- Recommendation: low-risk route with a narrow surface.
|
- Recommendation: low-risk route with a narrow surface.
|
||||||
|
|
||||||
#### `import`
|
#### `import`
|
||||||
- Current owner: Legacy.
|
- Current owner: React.
|
||||||
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
|
- 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.
|
- Main surface: staging files, album and singles matching, suggestion cards, processing queue.
|
||||||
- Key coupling: settings-derived staging path assumptions and downstream library state.
|
- 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`. The import subtree now uses nested route paths, with `/import` redirecting to `/import/album` and `autoFilter` remaining in the search string; any remaining `import` references in `webui/static/stats-automations.js` belong to the broader automation feature set, not this page migration.
|
||||||
|
- Route-local workflow state lives in `webui/src/routes/import/-import.store.ts`, which keeps drafts and queue state alive while moving between album, singles, and auto views.
|
||||||
|
- Route plan: `webui/docs/migration/import-migration-plan.md`.
|
||||||
|
|
||||||
### Wave 2: Search split
|
### Wave 2: Search split
|
||||||
|
|
||||||
|
|
@ -264,9 +266,10 @@ Rollups:
|
||||||
- Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage.
|
- Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage.
|
||||||
|
|
||||||
## Final Recommendation
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
153
webui/index.html
153
webui/index.html
|
|
@ -6199,159 +6199,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import Page -->
|
|
||||||
<div class="page" id="import-page">
|
|
||||||
<div class="import-page-container">
|
|
||||||
<!-- Header with staging info -->
|
|
||||||
<div class="import-page-header">
|
|
||||||
<div class="import-page-title-row">
|
|
||||||
<h1 class="import-page-title"><img src="/static/import.png" class="page-header-icon" alt=""><span>Import Music</span></h1>
|
|
||||||
<button class="import-page-refresh-btn" onclick="importPageRefreshStaging()" title="Re-scan import folder">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z"/></svg>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-staging-bar" id="import-staging-bar">
|
|
||||||
<span class="import-staging-path" id="import-page-staging-path">Import folder: loading...</span>
|
|
||||||
<span class="import-staging-stats" id="import-page-staging-stats"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Processing Queue -->
|
|
||||||
<div class="import-page-queue hidden" id="import-page-queue">
|
|
||||||
<div class="import-page-queue-header">
|
|
||||||
<span class="import-page-queue-title">Processing</span>
|
|
||||||
<button class="import-page-queue-clear" id="import-page-queue-clear" onclick="importPageClearFinishedJobs()">Clear finished</button>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-queue-list" id="import-page-queue-list"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Bar -->
|
|
||||||
<div class="import-page-tab-bar">
|
|
||||||
<button class="import-page-tab" id="import-page-tab-auto" onclick="importPageSwitchTab('auto')">Auto</button>
|
|
||||||
<button class="import-page-tab active" id="import-page-tab-album" onclick="importPageSwitchTab('album')">Albums</button>
|
|
||||||
<button class="import-page-tab" id="import-page-tab-singles" onclick="importPageSwitchTab('singles')">Singles</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auto Import Tab -->
|
|
||||||
<div class="import-page-tab-content" id="import-page-auto-content">
|
|
||||||
<div class="auto-import-controls">
|
|
||||||
<div class="auto-import-toggle-row">
|
|
||||||
<label class="auto-import-toggle-label">
|
|
||||||
<input type="checkbox" id="auto-import-enabled" onchange="_autoImportToggle(this.checked)">
|
|
||||||
<span class="repair-toggle-slider"></span>
|
|
||||||
<span>Auto-Import</span>
|
|
||||||
</label>
|
|
||||||
<span class="auto-import-status" id="auto-import-status-text">Disabled</span>
|
|
||||||
<button class="auto-import-scan-now-btn" id="auto-import-scan-now" onclick="_autoImportScanNow()" title="Scan import folder now" style="display:none">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z"/></svg>
|
|
||||||
Scan Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="auto-import-settings-row" id="auto-import-settings-row" style="display:none;">
|
|
||||||
<label>Confidence: <input type="range" id="auto-import-confidence" min="50" max="100" value="90" oninput="document.getElementById('auto-import-conf-val').textContent=this.value+'%'"> <span id="auto-import-conf-val">90%</span></label>
|
|
||||||
<label>Interval: <select id="auto-import-interval" onchange="_autoImportSaveSettings()">
|
|
||||||
<option value="30">30s</option>
|
|
||||||
<option value="60" selected>60s</option>
|
|
||||||
<option value="120">2m</option>
|
|
||||||
<option value="300">5m</option>
|
|
||||||
</select></label>
|
|
||||||
<button class="watchlist-action-btn watchlist-action-secondary" onclick="_autoImportSaveSettings()">Save</button>
|
|
||||||
</div>
|
|
||||||
<!-- Live scan progress -->
|
|
||||||
<div class="auto-import-progress" id="auto-import-progress" style="display:none">
|
|
||||||
<div class="auto-import-progress-text" id="auto-import-progress-text">Scanning...</div>
|
|
||||||
<div class="auto-import-progress-bar"><div class="auto-import-progress-fill" id="auto-import-progress-fill"></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Stats summary -->
|
|
||||||
<div class="auto-import-stats" id="auto-import-stats" style="display:none">
|
|
||||||
<span class="auto-import-stat" id="auto-import-stat-imported">0 imported</span>
|
|
||||||
<span class="auto-import-stat auto-import-stat-review" id="auto-import-stat-review">0 review</span>
|
|
||||||
<span class="auto-import-stat auto-import-stat-failed" id="auto-import-stat-failed">0 failed</span>
|
|
||||||
</div>
|
|
||||||
<!-- Filter pills -->
|
|
||||||
<div class="auto-import-filters" id="auto-import-filters" style="display:none">
|
|
||||||
<button class="adl-pill active" data-filter="all" onclick="_autoImportSetFilter('all')">All</button>
|
|
||||||
<button class="adl-pill" data-filter="pending" onclick="_autoImportSetFilter('pending')">Needs Review</button>
|
|
||||||
<button class="adl-pill" data-filter="imported" onclick="_autoImportSetFilter('imported')">Imported</button>
|
|
||||||
<button class="adl-pill" data-filter="failed" onclick="_autoImportSetFilter('failed')">Failed</button>
|
|
||||||
<div style="flex:1"></div>
|
|
||||||
<button class="auto-import-batch-btn" id="auto-import-approve-all" onclick="_autoImportApproveAll()" style="display:none">Approve All</button>
|
|
||||||
<button class="auto-import-batch-btn auto-import-clear-btn" id="auto-import-clear-completed" onclick="_autoImportClearCompleted()" style="display:none">Clear History</button>
|
|
||||||
</div>
|
|
||||||
<div class="auto-import-results" id="auto-import-results">
|
|
||||||
<div class="auto-import-empty">
|
|
||||||
<p>Enable auto-import to watch your import folder for new music.</p>
|
|
||||||
<p style="opacity:0.5;font-size:12px;">Drop album folders or single tracks into your import folder and SoulSync will identify, match, and import them automatically.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Album Tab -->
|
|
||||||
<div class="import-page-tab-content active" id="import-page-album-content">
|
|
||||||
<!-- Search state -->
|
|
||||||
<div id="import-page-album-search-section">
|
|
||||||
<div class="import-page-suggestions" id="import-page-suggestions">
|
|
||||||
<div class="import-page-section-label">Suggested from your import folder</div>
|
|
||||||
<div class="import-page-album-grid" id="import-page-suggestions-grid"></div>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-search-bar">
|
|
||||||
<input type="text" id="import-page-album-search-input" class="import-page-search-input"
|
|
||||||
placeholder="Search for an album..." onkeydown="if(event.key==='Enter')importPageSearchAlbum()">
|
|
||||||
<button class="import-page-search-btn" onclick="importPageSearchAlbum()">Search</button>
|
|
||||||
<button class="import-page-clear-btn hidden" id="import-page-album-clear-btn"
|
|
||||||
onclick="importPageResetAlbumSearch()" title="Clear search">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-album-grid" id="import-page-album-results"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Match state (hidden initially) -->
|
|
||||||
<div id="import-page-album-match-section" class="hidden">
|
|
||||||
<div class="import-page-album-hero" id="import-page-album-hero"></div>
|
|
||||||
<div class="import-page-match-header">
|
|
||||||
<h3>Track Matching</h3>
|
|
||||||
<div class="import-page-match-actions">
|
|
||||||
<button class="import-page-secondary-btn" onclick="importPageAutoRematch()">Re-match Automatically</button>
|
|
||||||
<button class="import-page-back-btn" onclick="importPageResetAlbumSearch()">Back to Search</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-match-list" id="import-page-match-list"></div>
|
|
||||||
|
|
||||||
<!-- Unmatched file pool for drag-drop -->
|
|
||||||
<div class="import-page-unmatched-pool" id="import-page-unmatched-pool">
|
|
||||||
<div class="import-page-pool-label">Unmatched Files (<span id="import-page-unmatched-count">0</span>)</div>
|
|
||||||
<div class="import-page-pool-chips" id="import-page-pool-chips"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="import-page-match-footer">
|
|
||||||
<div class="import-page-match-stats" id="import-page-match-stats"></div>
|
|
||||||
<button class="import-page-process-btn" id="import-page-album-process-btn"
|
|
||||||
onclick="importPageProcessAlbum()">Process Album</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Singles Tab -->
|
|
||||||
<div class="import-page-tab-content" id="import-page-singles-content">
|
|
||||||
<div class="import-page-singles-header">
|
|
||||||
<div class="import-page-singles-actions">
|
|
||||||
<button class="import-page-secondary-btn" onclick="importPageSelectAllSingles()">
|
|
||||||
<span id="import-page-select-all-text">Select All</span>
|
|
||||||
</button>
|
|
||||||
<button class="import-page-process-btn" id="import-page-singles-process-btn"
|
|
||||||
onclick="importPageProcessSingles()" disabled>Process Selected (0)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-singles-list" id="import-page-singles-list">
|
|
||||||
<div class="import-page-empty-state">Navigate to this page to scan your import folder for audio files.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Help & Docs Page -->
|
<!-- Help & Docs Page -->
|
||||||
<div class="page" id="help-page">
|
<div class="page" id="help-page">
|
||||||
<div class="docs-layout">
|
<div class="docs-layout">
|
||||||
|
|
|
||||||
32
webui/package-lock.json
generated
32
webui/package-lock.json
generated
|
|
@ -15,7 +15,8 @@
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.4.2"
|
"zod": "^4.4.2",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
|
@ -5391,6 +5392,35 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.4.2"
|
"zod": "^4.4.2",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@
|
||||||
.select {
|
.select {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background:
|
background:
|
||||||
|
|
@ -95,6 +96,7 @@
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
min-height: 36px;
|
||||||
padding: 8px 32px 8px 12px;
|
padding: 8px 32px 8px 12px;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.18s ease,
|
border-color 0.18s ease,
|
||||||
|
|
@ -105,28 +107,204 @@
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)),
|
||||||
|
rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)),
|
||||||
|
rgba(255, 255, 255, 0.07);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='sm'] {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 30px 6px 10px;
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 0,
|
||||||
|
right 10px center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& option,
|
||||||
|
& optgroup {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select:hover {
|
.checkbox {
|
||||||
border-color: rgba(255, 255, 255, 0.16);
|
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;
|
||||||
|
&[data-checked] {
|
||||||
|
border-color: rgb(var(--accent-light-rgb));
|
||||||
|
background: rgb(var(--accent-light-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-focused] {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-checked] .checkboxIndicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxIndicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxIcon {
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-0.5px) scaleX(1.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 3px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 999px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04)),
|
||||||
rgba(255, 255, 255, 0.06);
|
rgba(255, 255, 255, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
color-scheme: dark;
|
||||||
|
&[data-checked] {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(var(--accent-light-rgb), 0.55), rgba(var(--accent-rgb), 0.8)),
|
||||||
|
rgba(var(--accent-rgb), 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-focused] {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-checked] .switchThumb {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select:focus {
|
.switchThumb {
|
||||||
outline: none;
|
display: block;
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.55);
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)),
|
radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%),
|
||||||
rgba(255, 255, 255, 0.07);
|
rgb(var(--accent-light-rgb));
|
||||||
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select option,
|
.rangeThumb:hover {
|
||||||
.select optgroup {
|
transform: scale(1.04);
|
||||||
background: #1a1a2e;
|
}
|
||||||
color: #fff;
|
|
||||||
|
.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 {
|
.optionCardGroup {
|
||||||
|
|
@ -154,25 +332,24 @@
|
||||||
border-color 0.18s ease,
|
border-color 0.18s ease,
|
||||||
box-shadow 0.18s ease,
|
box-shadow 0.18s ease,
|
||||||
background 0.18s ease;
|
background 0.18s ease;
|
||||||
}
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
|
||||||
|
rgba(255, 255, 255, 0.04);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.optionCard:hover {
|
&[data-selected='true'] {
|
||||||
transform: translateY(-1px);
|
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||||
border-color: rgba(255, 255, 255, 0.14);
|
background:
|
||||||
background:
|
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
|
rgba(255, 255, 255, 0.05);
|
||||||
rgba(255, 255, 255, 0.04);
|
box-shadow:
|
||||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
|
0 0 0 1px rgba(var(--accent-light-rgb), 0.1),
|
||||||
}
|
0 14px 32px rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
.optionCardSelected {
|
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.45);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
|
|
||||||
rgba(255, 255, 255, 0.05);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(var(--accent-light-rgb), 0.1),
|
|
||||||
0 14px 32px rgba(0, 0, 0, 0.26);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.optionCardIcon {
|
.optionCardIcon {
|
||||||
|
|
@ -206,9 +383,22 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
&[data-size='sm'] {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='sm'] .optionButton {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.optionButton {
|
.optionButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
|
@ -218,24 +408,45 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
padding: 10px 14px;
|
padding: 8px 16px;
|
||||||
transition:
|
transition:
|
||||||
transform 0.18s ease,
|
transform 0.18s ease,
|
||||||
border-color 0.18s ease,
|
border-color 0.18s ease,
|
||||||
box-shadow 0.18s ease,
|
box-shadow 0.18s ease,
|
||||||
background 0.18s ease;
|
background 0.18s ease;
|
||||||
}
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.optionButton:hover {
|
&[data-variant='ghost'] {
|
||||||
transform: translateY(-1px);
|
border-color: transparent;
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
background: transparent;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
color: rgba(255, 255, 255, 0.58);
|
||||||
}
|
}
|
||||||
|
|
||||||
.optionButtonSelected {
|
&[data-variant='ghost']:hover:not(:disabled) {
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.5);
|
transform: none;
|
||||||
background: rgba(var(--accent-rgb), 0.18);
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-selected='true'] {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.5);
|
||||||
|
background: rgba(var(--accent-rgb), 0.18);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-selected='true']:hover {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.62);
|
||||||
|
background: rgba(var(--accent-rgb), 0.26);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(var(--accent-light-rgb), 0.12),
|
||||||
|
0 10px 24px rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
|
@ -260,24 +471,118 @@
|
||||||
box-shadow 0.18s ease,
|
box-shadow 0.18s ease,
|
||||||
background 0.18s ease,
|
background 0.18s ease,
|
||||||
color 0.18s ease;
|
color 0.18s ease;
|
||||||
}
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.button:hover:not(:disabled) {
|
&:focus-visible {
|
||||||
transform: translateY(-1px);
|
outline: none;
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:focus-visible {
|
&:disabled {
|
||||||
outline: none;
|
opacity: 0.55;
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.55);
|
cursor: not-allowed;
|
||||||
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled {
|
&[data-size='sm'] {
|
||||||
opacity: 0.55;
|
min-height: 32px;
|
||||||
cursor: not-allowed;
|
padding: 6px 10px;
|
||||||
transform: none;
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='lg'] {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='icon'] {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary'] {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||||
|
background: rgb(var(--accent-light-rgb));
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary']:hover:not(:disabled) {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
|
background: rgba(var(--accent-light-rgb), 0.95);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary']:focus-visible {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.8);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary']:disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
background: rgba(var(--accent-light-rgb), 0.45);
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary'] [data-slot='badge'] {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
border-color: rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='secondary'] {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='secondary']:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='secondary']:focus-visible {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='secondary']:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost'] {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost']:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost']:focus-visible {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.42);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost']:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.formError {
|
.formError {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormError,
|
FormError,
|
||||||
FormField,
|
FormField,
|
||||||
|
|
@ -11,7 +12,9 @@ import {
|
||||||
OptionButtonGroup,
|
OptionButtonGroup,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
OptionCardGroup,
|
OptionCardGroup,
|
||||||
|
RangeInput,
|
||||||
Select,
|
Select,
|
||||||
|
Switch,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from './form';
|
} from './form';
|
||||||
|
|
@ -22,6 +25,9 @@ function FormDemo() {
|
||||||
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
||||||
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
||||||
const [status, setStatus] = useState('open');
|
const [status, setStatus] = useState('open');
|
||||||
|
const [archive, setArchive] = useState(false);
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [confidence, setConfidence] = useState(90);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
|
|
@ -90,11 +96,31 @@ function FormDemo() {
|
||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Archive" helperText="Shared checkbox primitive">
|
||||||
|
<Checkbox checked={archive} onCheckedChange={setArchive} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Enabled" helperText="Shared switch primitive">
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Confidence" helperText="Shared range primitive">
|
||||||
|
<RangeInput
|
||||||
|
label="Confidence"
|
||||||
|
min={50}
|
||||||
|
max={100}
|
||||||
|
value={confidence}
|
||||||
|
onValueChange={setConfidence}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormError message="Validation failed" />
|
<FormError message="Validation failed" />
|
||||||
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button type="button">Cancel</Button>
|
<Button type="button">Cancel</Button>
|
||||||
<Button type="submit">Save</Button>
|
<Button type="submit" variant="primary">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
@ -109,9 +135,25 @@ describe('form primitives', () => {
|
||||||
expect(screen.getByText('Short summary')).toBeInTheDocument();
|
expect(screen.getByText('Short summary')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
|
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
|
||||||
expect(screen.getByLabelText('Status')).toHaveValue('open');
|
expect(screen.getByLabelText('Status')).toHaveValue('open');
|
||||||
|
expect(screen.getByLabelText('Status')).toHaveAttribute('data-size', 'md');
|
||||||
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
|
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
|
||||||
expect(screen.getByLabelText('Status')).toHaveValue('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 enabledSwitch = screen.getByRole('switch', { name: 'Enabled' });
|
||||||
|
expect(enabledSwitch).toBeChecked();
|
||||||
|
fireEvent.click(enabledSwitch);
|
||||||
|
expect(enabledSwitch).not.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 wrongCover = screen.getByRole('button', { name: /wrong cover/i });
|
||||||
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
||||||
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
|
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
@ -126,6 +168,32 @@ describe('form primitives', () => {
|
||||||
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
|
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('data-variant', 'primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports compact option button groups', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<OptionButtonGroup size="sm">
|
||||||
|
<OptionButton selected>All</OptionButton>
|
||||||
|
<OptionButton variant="ghost">Pending</OptionButton>
|
||||||
|
</OptionButtonGroup>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-size="sm"]')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Pending' })).toHaveAttribute(
|
||||||
|
'data-variant',
|
||||||
|
'ghost',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports compact select sizing', () => {
|
||||||
|
render(
|
||||||
|
<Select aria-label="Compact" defaultValue="one" size="sm">
|
||||||
|
<option value="one">One</option>
|
||||||
|
<option value="two">Two</option>
|
||||||
|
</Select>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Compact')).toHaveAttribute('data-size', 'sm');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { Button as BaseButton } from '@base-ui/react/button';
|
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 { Field } from '@base-ui/react/field';
|
||||||
import { Input as BaseInput } from '@base-ui/react/input';
|
import { Input as BaseInput } from '@base-ui/react/input';
|
||||||
|
import { Slider } from '@base-ui/react/slider';
|
||||||
|
import { Switch as BaseSwitch } from '@base-ui/react/switch';
|
||||||
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
type CSSProperties,
|
||||||
type ComponentPropsWithoutRef,
|
type ComponentPropsWithoutRef,
|
||||||
type ButtonHTMLAttributes,
|
type ButtonHTMLAttributes,
|
||||||
type SelectHTMLAttributes,
|
type SelectHTMLAttributes,
|
||||||
|
|
@ -73,13 +77,118 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
|
||||||
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
|
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
export type SelectSize = 'sm' | 'md';
|
||||||
|
|
||||||
|
export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'className' | 'size'> & {
|
||||||
|
className?: string;
|
||||||
|
size?: SelectSize;
|
||||||
|
};
|
||||||
|
|
||||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||||
|
{ className, size = 'md', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<select ref={ref} className={clsx(styles.select, className)} data-size={size} {...props} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type BaseCheckboxProps = ComponentPropsWithoutRef<typeof BaseCheckbox.Root>;
|
||||||
|
|
||||||
|
export type CheckboxProps = Omit<BaseCheckboxProps, 'className' | 'children'> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Checkbox = forwardRef<HTMLElement, CheckboxProps>(function Checkbox(
|
||||||
{ className, ...props },
|
{ className, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
|
return (
|
||||||
|
<BaseCheckbox.Root ref={ref} className={clsx(styles.checkbox, className)} {...props}>
|
||||||
|
<BaseCheckbox.Indicator className={styles.checkboxIndicator}>
|
||||||
|
<span className={styles.checkboxIcon} aria-hidden="true">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
</BaseCheckbox.Indicator>
|
||||||
|
</BaseCheckbox.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type BaseSwitchProps = ComponentPropsWithoutRef<typeof BaseSwitch.Root>;
|
||||||
|
|
||||||
|
export type SwitchProps = Omit<BaseSwitchProps, 'className' | 'children'> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Switch = forwardRef<HTMLElement, SwitchProps>(function Switch(
|
||||||
|
{ className, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<BaseSwitch.Root ref={ref} className={clsx(styles.switch, className)} {...props}>
|
||||||
|
<BaseSwitch.Thumb className={styles.switchThumb} />
|
||||||
|
</BaseSwitch.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RangeInputProps {
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
defaultValue?: number;
|
||||||
|
label?: ReactNode;
|
||||||
|
max?: number;
|
||||||
|
min?: number;
|
||||||
|
name?: string;
|
||||||
|
step?: number;
|
||||||
|
style?: CSSProperties;
|
||||||
|
value?: number;
|
||||||
|
onValueChange?: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeInput = forwardRef<HTMLDivElement, RangeInputProps>(function RangeInput(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
max = 100,
|
||||||
|
min = 0,
|
||||||
|
name,
|
||||||
|
onValueChange,
|
||||||
|
step = 1,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Slider.Root
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.rangeRoot, className)}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
thumbAlignment="edge"
|
||||||
|
style={style}
|
||||||
|
disabled={disabled}
|
||||||
|
name={name}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onValueChange={(nextValue) => {
|
||||||
|
onValueChange?.(Array.isArray(nextValue) ? nextValue[0] : nextValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Slider.Control className={styles.rangeControl}>
|
||||||
|
<Slider.Track className={styles.rangeTrack}>
|
||||||
|
<Slider.Indicator className={styles.rangeIndicator} />
|
||||||
|
<Slider.Thumb
|
||||||
|
aria-label={typeof label === 'string' ? label : undefined}
|
||||||
|
className={styles.rangeThumb}
|
||||||
|
/>
|
||||||
|
</Slider.Track>
|
||||||
|
</Slider.Control>
|
||||||
|
</Slider.Root>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function OptionCardGroup({
|
export function OptionCardGroup({
|
||||||
|
|
@ -112,7 +221,8 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
|
||||||
<BaseToggle
|
<BaseToggle
|
||||||
ref={ref}
|
ref={ref}
|
||||||
pressed={selected}
|
pressed={selected}
|
||||||
className={clsx(styles.optionCard, selected && styles.optionCardSelected, className)}
|
className={clsx(styles.optionCard, className)}
|
||||||
|
data-selected={selected ? 'true' : undefined}
|
||||||
type={type}
|
type={type}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
@ -129,31 +239,42 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function OptionButtonGroup({
|
export type OptionButtonGroupSize = 'sm' | 'md';
|
||||||
className,
|
|
||||||
children,
|
export interface OptionButtonGroupProps {
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
size?: OptionButtonGroupSize;
|
||||||
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OptionButtonGroup({ className, children, size = 'md' }: OptionButtonGroupProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.optionButtonGroup, className)} data-size={size}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OptionButtonVariant = 'default' | 'ghost';
|
||||||
|
|
||||||
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
|
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
|
||||||
className?: string;
|
className?: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
variant?: OptionButtonVariant;
|
||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
|
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
|
||||||
{ className, children, selected = false, type = 'button', ...props },
|
{ className, children, selected = false, type = 'button', variant = 'default', ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<BaseToggle
|
<BaseToggle
|
||||||
ref={ref}
|
ref={ref}
|
||||||
pressed={selected}
|
pressed={selected}
|
||||||
className={clsx(styles.optionButton, selected && styles.optionButtonSelected, className)}
|
className={clsx(styles.optionButton, className)}
|
||||||
|
data-selected={selected ? 'true' : undefined}
|
||||||
|
data-variant={variant}
|
||||||
type={type}
|
type={type}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
@ -166,13 +287,24 @@ type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;
|
||||||
|
|
||||||
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
|
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'icon';
|
||||||
|
variant?: 'default' | 'primary' | 'secondary' | 'ghost';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
{ className, type = 'button', ...props },
|
{ className, size = 'md', type = 'button', variant = 'default', ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return <BaseButton ref={ref} className={clsx(styles.button, className)} type={type} {...props} />;
|
return (
|
||||||
|
<BaseButton
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.button, className)}
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
type={type}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
|
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export { Show } from './show';
|
export * from './primitives';
|
||||||
|
|
|
||||||
87
webui/src/components/primitives/primitives.module.css
Normal file
87
webui/src/components/primitives/primitives.module.css
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
.notice {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='neutral'] {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='info'] {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.24);
|
||||||
|
background: rgba(var(--accent-light-rgb), 0.08);
|
||||||
|
color: rgba(255, 232, 188, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='success'] {
|
||||||
|
border-color: rgba(110, 220, 150, 0.26);
|
||||||
|
background: rgba(110, 220, 150, 0.08);
|
||||||
|
color: rgba(210, 255, 228, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='warning'] {
|
||||||
|
border-color: rgba(255, 200, 100, 0.26);
|
||||||
|
background: rgba(255, 200, 100, 0.08);
|
||||||
|
color: rgba(255, 232, 188, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='danger'] {
|
||||||
|
border-color: rgba(255, 120, 120, 0.26);
|
||||||
|
background: rgba(255, 120, 120, 0.08);
|
||||||
|
color: rgba(255, 214, 214, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2ch;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='info'] {
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='success'] {
|
||||||
|
color: #4ade80;
|
||||||
|
background: rgba(74, 222, 128, 0.12);
|
||||||
|
border-color: rgba(74, 222, 128, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='warning'] {
|
||||||
|
color: #fbbf24;
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
border-color: rgba(251, 191, 36, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='danger'] {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
border-color: rgba(248, 113, 113, 0.12);
|
||||||
|
}
|
||||||
67
webui/src/components/primitives/primitives.test.tsx
Normal file
67
webui/src/components/primitives/primitives.test.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Badge, Notice, Show } from './primitives';
|
||||||
|
|
||||||
|
describe('Show', () => {
|
||||||
|
it('renders children when the condition is true', () => {
|
||||||
|
render(
|
||||||
|
<Show when={true}>
|
||||||
|
<span>Visible</span>
|
||||||
|
</Show>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Visible')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback when the condition is false', () => {
|
||||||
|
render(
|
||||||
|
<Show fallback={<span>Hidden</span>} when={false}>
|
||||||
|
<span>Visible</span>
|
||||||
|
</Show>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Hidden')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Visible')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports render-prop children', () => {
|
||||||
|
render(<Show when="Ada">{(name) => <span>{name}</span>}</Show>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Ada')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notice', () => {
|
||||||
|
it('renders as a note by default', () => {
|
||||||
|
render(<Notice>Fallback message</Notice>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Fallback message')).toHaveAttribute('role', 'note');
|
||||||
|
expect(screen.getByText('Fallback message')).toHaveAttribute('data-tone', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports tone overrides', () => {
|
||||||
|
render(
|
||||||
|
<Notice tone="warning">
|
||||||
|
<span>Provider fallback</span>
|
||||||
|
</Notice>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('note')).toHaveAttribute('data-tone', 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Badge', () => {
|
||||||
|
it('renders with neutral styling by default', () => {
|
||||||
|
render(<Badge>12</Badge>);
|
||||||
|
|
||||||
|
expect(screen.getByText('12')).toHaveAttribute('data-slot', 'badge');
|
||||||
|
expect(screen.getByText('12')).toHaveAttribute('data-tone', 'neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports tone overrides', () => {
|
||||||
|
render(<Badge tone="warning">12</Badge>);
|
||||||
|
|
||||||
|
expect(screen.getByText('12')).toHaveAttribute('data-tone', 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
70
webui/src/components/primitives/primitives.tsx
Normal file
70
webui/src/components/primitives/primitives.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import styles from './primitives.module.css';
|
||||||
|
|
||||||
|
type ShowChildren<T> = ReactNode | ((value: NonNullable<T>) => ReactNode);
|
||||||
|
|
||||||
|
export interface ShowProps<T> {
|
||||||
|
children: ShowChildren<T>;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
when: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Show<T>({ fallback = null, children, when }: ShowProps<T>) {
|
||||||
|
if (!when) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof children === 'function') {
|
||||||
|
return <>{(children as (value: NonNullable<T>) => ReactNode)(when as NonNullable<T>)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseBadgeProps = ComponentPropsWithoutRef<'span'>;
|
||||||
|
|
||||||
|
export type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export type BadgeProps = Omit<BaseBadgeProps, 'className'> & {
|
||||||
|
className?: string;
|
||||||
|
tone?: BadgeTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
|
||||||
|
{ className, tone = 'neutral', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.badge, className)}
|
||||||
|
data-slot="badge"
|
||||||
|
data-tone={tone}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NoticeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export type NoticeProps = Omit<ComponentPropsWithoutRef<'div'>, 'className'> & {
|
||||||
|
className?: string;
|
||||||
|
tone?: NoticeTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Notice = forwardRef<HTMLDivElement, NoticeProps>(function Notice(
|
||||||
|
{ className, tone = 'info', role = 'note', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.notice, className)}
|
||||||
|
data-tone={tone}
|
||||||
|
role={role}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { Show } from './show';
|
|
||||||
|
|
||||||
describe('Show', () => {
|
|
||||||
it('renders children when the condition is true', () => {
|
|
||||||
render(
|
|
||||||
<Show when={true}>
|
|
||||||
<span>Visible</span>
|
|
||||||
</Show>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Visible')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders fallback when the condition is false', () => {
|
|
||||||
render(
|
|
||||||
<Show fallback={<span>Hidden</span>} when={false}>
|
|
||||||
<span>Visible</span>
|
|
||||||
</Show>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Hidden')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Visible')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports render-prop children', () => {
|
|
||||||
render(<Show when="Ada">{(name) => <span>{name}</span>}</Show>);
|
|
||||||
|
|
||||||
expect(screen.getByText('Ada')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
type ShowChildren<T> = ReactNode | ((value: NonNullable<T>) => ReactNode);
|
|
||||||
|
|
||||||
export function Show<T>({
|
|
||||||
fallback = null,
|
|
||||||
children,
|
|
||||||
when,
|
|
||||||
}: {
|
|
||||||
children: ShowChildren<T>;
|
|
||||||
fallback?: ReactNode;
|
|
||||||
when: T;
|
|
||||||
}) {
|
|
||||||
if (!when) {
|
|
||||||
return <>{fallback}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof children === 'function') {
|
|
||||||
return <>{(children as (value: NonNullable<T>) => ReactNode)(when as NonNullable<T>)}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
7
webui/src/platform/shell/globals.d.ts
vendored
7
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -9,6 +9,13 @@ import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './b
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
|
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
|
||||||
|
showConfirmDialog?: (options?: {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
}) => Promise<boolean>;
|
||||||
SoulSyncIssueDomain?: IssueDomainBridge;
|
SoulSyncIssueDomain?: IssueDomainBridge;
|
||||||
SoulSyncWorkflowActions?: {
|
SoulSyncWorkflowActions?: {
|
||||||
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;
|
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ describe('shellRouteManifest', () => {
|
||||||
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
|
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
|
||||||
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
|
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
|
||||||
expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
|
expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
|
||||||
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe('artist-detail');
|
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe(
|
||||||
|
'artist-detail',
|
||||||
|
);
|
||||||
expect(resolveShellPageFromPath('/artists')).toBeNull();
|
expect(resolveShellPageFromPath('/artists')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -31,6 +33,13 @@ describe('shellRouteManifest', () => {
|
||||||
expect(resolveShellPageFromPath('/issues/')).toBe('issues');
|
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', () => {
|
it('keeps a route entry for every manifest page id', () => {
|
||||||
expect(shellRouteManifest).not.toHaveLength(0);
|
expect(shellRouteManifest).not.toHaveLength(0);
|
||||||
expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard');
|
expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard');
|
||||||
|
|
@ -43,8 +52,9 @@ describe('shellRouteManifest', () => {
|
||||||
it('tracks whether a route is rendered by React or the legacy shell', () => {
|
it('tracks whether a route is rendered by React or the legacy shell', () => {
|
||||||
expect(getShellRouteByPageId('issues')?.kind).toBe('react');
|
expect(getShellRouteByPageId('issues')?.kind).toBe('react');
|
||||||
expect(getShellRouteByPageId('stats')?.kind).toBe('react');
|
expect(getShellRouteByPageId('stats')?.kind).toBe('react');
|
||||||
|
expect(getShellRouteByPageId('import')?.kind).toBe('react');
|
||||||
expect(getShellRouteByPageId('discover')?.kind).toBe('legacy');
|
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);
|
expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [
|
||||||
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
|
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
|
||||||
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
|
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
|
||||||
{ pageId: 'active-downloads', path: '/active-downloads', 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: 'library', path: '/library', kind: 'legacy' },
|
||||||
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
|
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
|
||||||
{ pageId: 'artist-detail', path: '/artist-detail', 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 {
|
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 {
|
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,12 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as SplatRouteImport } from './routes/$'
|
import { Route as SplatRouteImport } from './routes/$'
|
||||||
import { Route as StatsRouteRouteImport } from './routes/stats/route'
|
import { Route as StatsRouteRouteImport } from './routes/stats/route'
|
||||||
import { Route as IssuesRouteRouteImport } from './routes/issues/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 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'
|
import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id'
|
||||||
|
|
||||||
const SplatRoute = SplatRouteImport.update({
|
const SplatRoute = SplatRouteImport.update({
|
||||||
|
|
@ -30,11 +35,36 @@ const IssuesRouteRoute = IssuesRouteRouteImport.update({
|
||||||
path: '/issues',
|
path: '/issues',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ImportRouteRoute = ImportRouteRouteImport.update({
|
||||||
|
id: '/import',
|
||||||
|
path: '/import',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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({
|
const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
|
||||||
id: '/artist-detail/$source/$id',
|
id: '/artist-detail/$source/$id',
|
||||||
path: '/artist-detail/$source/$id',
|
path: '/artist-detail/$source/$id',
|
||||||
|
|
@ -43,9 +73,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/import': typeof ImportRouteRouteWithChildren
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
'/stats': typeof StatsRouteRoute
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
|
'/import/album': typeof ImportAlbumRoute
|
||||||
|
'/import/auto': typeof ImportAutoRoute
|
||||||
|
'/import/singles': typeof ImportSinglesRoute
|
||||||
|
'/import/': typeof ImportIndexRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
|
|
@ -53,32 +88,66 @@ export interface FileRoutesByTo {
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
'/stats': typeof StatsRouteRoute
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
|
'/import/album': typeof ImportAlbumRoute
|
||||||
|
'/import/auto': typeof ImportAutoRoute
|
||||||
|
'/import/singles': typeof ImportSinglesRoute
|
||||||
|
'/import': typeof ImportIndexRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/import': typeof ImportRouteRouteWithChildren
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
'/stats': typeof StatsRouteRoute
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
|
'/import/album': typeof ImportAlbumRoute
|
||||||
|
'/import/auto': typeof ImportAutoRoute
|
||||||
|
'/import/singles': typeof ImportSinglesRoute
|
||||||
|
'/import/': typeof ImportIndexRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
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
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
|
to:
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/'
|
| '/'
|
||||||
| '/issues'
|
| '/issues'
|
||||||
| '/stats'
|
| '/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'
|
| '/artist-detail/$source/$id'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
ImportRouteRoute: typeof ImportRouteRouteWithChildren
|
||||||
IssuesRouteRoute: typeof IssuesRouteRoute
|
IssuesRouteRoute: typeof IssuesRouteRoute
|
||||||
StatsRouteRoute: typeof StatsRouteRoute
|
StatsRouteRoute: typeof StatsRouteRoute
|
||||||
SplatRoute: typeof SplatRoute
|
SplatRoute: typeof SplatRoute
|
||||||
|
|
@ -108,6 +177,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IssuesRouteRouteImport
|
preLoaderRoute: typeof IssuesRouteRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/import': {
|
||||||
|
id: '/import'
|
||||||
|
path: '/import'
|
||||||
|
fullPath: '/import'
|
||||||
|
preLoaderRoute: typeof ImportRouteRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
|
|
@ -115,6 +191,34 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/artist-detail/$source/$id': {
|
||||||
id: '/artist-detail/$source/$id'
|
id: '/artist-detail/$source/$id'
|
||||||
path: '/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 = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
ImportRouteRoute: ImportRouteRouteWithChildren,
|
||||||
IssuesRouteRoute: IssuesRouteRoute,
|
IssuesRouteRoute: IssuesRouteRoute,
|
||||||
StatsRouteRoute: StatsRouteRoute,
|
StatsRouteRoute: StatsRouteRoute,
|
||||||
SplatRoute: SplatRoute,
|
SplatRoute: SplatRoute,
|
||||||
|
|
|
||||||
29
webui/src/routes/import/-import.api.test.ts
Normal file
29
webui/src/routes/import/-import.api.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { HttpResponse, http, server } from '@/test/msw';
|
||||||
|
|
||||||
|
import { approveAutoImportResult, rejectAutoImportResult } from './-import.api';
|
||||||
|
|
||||||
|
const softFailureMessage = 'Item not found or not pending review';
|
||||||
|
|
||||||
|
describe('import api', () => {
|
||||||
|
it('surfaces soft failures from auto-import approval endpoints', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/auto-import/approve/17', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: softFailureMessage,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.post('/api/auto-import/reject/18', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: softFailureMessage,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(approveAutoImportResult(17)).rejects.toThrow(softFailureMessage);
|
||||||
|
await expect(rejectAutoImportResult(18)).rejects.toThrow(softFailureMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
235
webui/src/routes/import/-import.api.ts
Normal file
235
webui/src/routes/import/-import.api.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
export async function fetchImportStagingFiles(): Promise<ImportStagingFilesPayload> {
|
||||||
|
return readJson<ImportStagingFilesPayload>(apiClient.get('import/staging/files'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImportStagingGroups(): Promise<ImportStagingGroupsPayload> {
|
||||||
|
return readJson<ImportStagingGroupsPayload>(apiClient.get('import/staging/groups'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImportStagingSuggestions(): Promise<ImportAlbumSearchPayload> {
|
||||||
|
return readJson<ImportAlbumSearchPayload>(apiClient.get('import/staging/suggestions'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchImportAlbums(query: string): Promise<ImportAlbumSearchPayload> {
|
||||||
|
return readJson<ImportAlbumSearchPayload>(
|
||||||
|
apiClient.get('import/search/albums', {
|
||||||
|
searchParams: {
|
||||||
|
q: query,
|
||||||
|
limit: '12',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function matchImportAlbum(input: {
|
||||||
|
albumId: string;
|
||||||
|
source?: string | null;
|
||||||
|
albumName?: string | null;
|
||||||
|
albumArtist?: string | null;
|
||||||
|
filePaths?: string[] | null;
|
||||||
|
}): Promise<ImportAlbumMatchPayload> {
|
||||||
|
return readJson<ImportAlbumMatchPayload>(
|
||||||
|
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 } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processImportAlbumTrack(input: {
|
||||||
|
album: ImportAlbum;
|
||||||
|
match: ImportAlbumMatch;
|
||||||
|
}): Promise<ImportProcessPayload> {
|
||||||
|
return readJson<ImportProcessPayload>(
|
||||||
|
apiClient.post('import/album/process', {
|
||||||
|
json: {
|
||||||
|
album: input.album,
|
||||||
|
matches: [input.match],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchImportTracks(query: string): Promise<ImportTrackSearchPayload> {
|
||||||
|
return readJson<ImportTrackSearchPayload>(
|
||||||
|
apiClient.get('import/search/tracks', {
|
||||||
|
searchParams: {
|
||||||
|
q: query,
|
||||||
|
limit: '6',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processImportSingleFile(file: unknown): Promise<ImportProcessPayload> {
|
||||||
|
return readJson<ImportProcessPayload>(
|
||||||
|
apiClient.post('import/singles/process', {
|
||||||
|
json: {
|
||||||
|
files: [file],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAutoImportStatus(): Promise<ImportAutoImportStatusPayload> {
|
||||||
|
return readJson<ImportAutoImportStatusPayload>(apiClient.get('auto-import/status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAutoImportSettings(): Promise<ImportAutoImportSettingsPayload> {
|
||||||
|
return readJson<ImportAutoImportSettingsPayload>(apiClient.get('auto-import/settings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAutoImportSettings(input: {
|
||||||
|
confidenceThreshold: number;
|
||||||
|
scanInterval: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
await readJson<{ success: boolean; error?: string }>(
|
||||||
|
apiClient.post('auto-import/settings', {
|
||||||
|
json: {
|
||||||
|
confidence_threshold: input.confidenceThreshold,
|
||||||
|
scan_interval: input.scanInterval,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAutoImportResults(): Promise<ImportAutoImportResultsPayload> {
|
||||||
|
return readJson<ImportAutoImportResultsPayload>(
|
||||||
|
apiClient.get('auto-import/results', {
|
||||||
|
searchParams: {
|
||||||
|
limit: '100',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleAutoImport(enabled: boolean): Promise<void> {
|
||||||
|
await readJson<{ success: boolean; error?: string }>(
|
||||||
|
apiClient.post('auto-import/toggle', {
|
||||||
|
json: { enabled },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerAutoImportScan(): Promise<void> {
|
||||||
|
await readJson<{ success: boolean; error?: string }>(apiClient.post('auto-import/scan-now'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveAutoImportResult(id: number): Promise<void> {
|
||||||
|
const payload = await readJson<{ success: boolean; error?: string }>(
|
||||||
|
apiClient.post(`auto-import/approve/${id}`),
|
||||||
|
);
|
||||||
|
if (!payload.success) {
|
||||||
|
throw new Error(payload.error || 'Failed to approve import');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectAutoImportResult(id: number): Promise<void> {
|
||||||
|
const payload = await readJson<{ success: boolean; error?: string }>(
|
||||||
|
apiClient.post(`auto-import/reject/${id}`),
|
||||||
|
);
|
||||||
|
if (!payload.success) {
|
||||||
|
throw new Error(payload.error || 'Failed to dismiss import');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveAllAutoImportResults(): Promise<number> {
|
||||||
|
const payload = await readJson<{ success: boolean; count?: number; error?: string }>(
|
||||||
|
apiClient.post('auto-import/approve-all'),
|
||||||
|
);
|
||||||
|
return payload.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCompletedAutoImportResults(): Promise<number> {
|
||||||
|
const payload = await readJson<{ success: boolean; count?: number; error?: string }>(
|
||||||
|
apiClient.post('auto-import/clear-completed'),
|
||||||
|
);
|
||||||
|
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'] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
344
webui/src/routes/import/-import.helpers.ts
Normal file
344
webui/src/routes/import/-import.helpers.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
import type {
|
||||||
|
ImportAlbumMatch,
|
||||||
|
ImportAutoFilter,
|
||||||
|
ImportAutoImportActiveItem,
|
||||||
|
ImportAutoImportMatchData,
|
||||||
|
ImportAutoImportResult,
|
||||||
|
ImportAutoImportStatusPayload,
|
||||||
|
ImportQueueEntry,
|
||||||
|
ImportStagingFile,
|
||||||
|
} from './-import.types';
|
||||||
|
|
||||||
|
export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png';
|
||||||
|
|
||||||
|
const IMPORT_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
amazon: 'Amazon Music',
|
||||||
|
deezer: 'Deezer',
|
||||||
|
discogs: 'Discogs',
|
||||||
|
hydrabase: 'Hydrabase',
|
||||||
|
itunes: 'Apple Music',
|
||||||
|
musicbrainz: 'MusicBrainz',
|
||||||
|
playlist: 'Playlist',
|
||||||
|
soulseek: 'Basic Search',
|
||||||
|
spotify: 'Spotify',
|
||||||
|
youtube_videos: 'Music Videos',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStagingFileKey(file: ImportStagingFile): string {
|
||||||
|
return file.full_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getImportSourceLabel(source: string | null | undefined): string {
|
||||||
|
if (!source) return '';
|
||||||
|
return IMPORT_SOURCE_LABELS[source.toLowerCase()] || source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label a fallback result row so it is obvious which provider actually returned it.
|
||||||
|
*/
|
||||||
|
export function getImportSourceBadgeText(
|
||||||
|
resultSource: string | null | undefined,
|
||||||
|
lookupSource: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!resultSource || !lookupSource) return '';
|
||||||
|
if (resultSource.toLowerCase() === lookupSource.toLowerCase()) return '';
|
||||||
|
return `via ${getImportSourceLabel(resultSource)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner for a whole result set that came from a fallback provider rather than the lookup source.
|
||||||
|
*/
|
||||||
|
export function getImportSourceFallbackBanner(
|
||||||
|
results: Array<{ source: string }> | null | undefined,
|
||||||
|
lookupSource: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!lookupSource || !results?.length) return '';
|
||||||
|
const normalizedLookupSource = lookupSource.toLowerCase();
|
||||||
|
if (
|
||||||
|
!results.every(
|
||||||
|
(result) => result.source && result.source.toLowerCase() !== normalizedLookupSource,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultSource = results[0]?.source;
|
||||||
|
if (!resultSource) return '';
|
||||||
|
|
||||||
|
return `Showing ${getImportSourceLabel(resultSource)} results - not from your primary source (${getImportSourceLabel(lookupSource)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number, number>,
|
||||||
|
): 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<number, number>,
|
||||||
|
): { 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<number, number>,
|
||||||
|
): 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 type AutoImportStatusTone = 'neutral' | 'info' | 'success';
|
||||||
|
|
||||||
|
export function getAutoImportStatusTone(
|
||||||
|
status: ImportAutoImportStatusPayload | undefined,
|
||||||
|
): AutoImportStatusTone {
|
||||||
|
if (!status?.running || status.paused) return 'neutral';
|
||||||
|
if (status.current_status === 'scanning' || status.current_status === 'processing') return 'info';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
completed: 'Imported',
|
||||||
|
pending_review: 'Needs Review',
|
||||||
|
needs_identification: 'Unidentified',
|
||||||
|
failed: 'Failed',
|
||||||
|
scanning: 'Scanning...',
|
||||||
|
matched: 'Matched',
|
||||||
|
rejected: 'Dismissed',
|
||||||
|
approved: 'Approved',
|
||||||
|
processing: 'Processing',
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
completed: '✓',
|
||||||
|
pending_review: '⚠',
|
||||||
|
needs_identification: '✗',
|
||||||
|
failed: '✗',
|
||||||
|
scanning: '⌛',
|
||||||
|
matched: '✓',
|
||||||
|
rejected: '✕',
|
||||||
|
approved: '✓',
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
267
webui/src/routes/import/-import.store.ts
Normal file
267
webui/src/routes/import/-import.store.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { combine } from 'zustand/middleware';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ImportAlbumMatchPayload,
|
||||||
|
ImportAlbumResult,
|
||||||
|
ImportQueueEntry,
|
||||||
|
ImportQueueJob,
|
||||||
|
ImportStagingFile,
|
||||||
|
ImportTrackResult,
|
||||||
|
} from './-import.types';
|
||||||
|
|
||||||
|
import { getStagingFileKey } from './-import.helpers';
|
||||||
|
|
||||||
|
export type SingleSearchState = {
|
||||||
|
query: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
results: ImportTrackResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StateUpdater<T> = T | ((current: T) => T);
|
||||||
|
|
||||||
|
function resolveState<T>(current: T, updater: StateUpdater<T>) {
|
||||||
|
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,
|
||||||
|
// Lookup source used to seed the album search. Result rows still carry their own `source`.
|
||||||
|
albumSearchLookupSource: null as string | null,
|
||||||
|
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<number, number>,
|
||||||
|
selectedSingles: new Set<string>(),
|
||||||
|
singlesManualMatches: {} as Record<string, ImportTrackResult>,
|
||||||
|
openSingleSearch: null as string | null,
|
||||||
|
singleSearches: {} as Record<string, SingleSearchState>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ImportQueueEntry>) => {
|
||||||
|
set((state) => ({
|
||||||
|
queue: state.queue.map((entry) => (entry.id === entryId ? { ...entry, ...patch } : entry)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
resetAlbumSearch: () => {
|
||||||
|
set({
|
||||||
|
albumQuery: '',
|
||||||
|
albumResults: null,
|
||||||
|
albumSearchError: null,
|
||||||
|
albumSearchLoading: false,
|
||||||
|
albumSearchLookupSource: null,
|
||||||
|
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 }),
|
||||||
|
setAlbumSearchLookupSource: (albumSearchLookupSource: string | null) =>
|
||||||
|
set({ albumSearchLookupSource }),
|
||||||
|
setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => {
|
||||||
|
set({
|
||||||
|
albumQuery,
|
||||||
|
albumSearchLoading: true,
|
||||||
|
albumSearchError: null,
|
||||||
|
albumResults: null,
|
||||||
|
albumSearchLookupSource: 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<Record<number, number>>) => {
|
||||||
|
set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) }));
|
||||||
|
},
|
||||||
|
toggleSingle: (fileKey: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const selectedSingles = new Set(state.selectedSingles);
|
||||||
|
if (selectedSingles.has(fileKey)) selectedSingles.delete(fileKey);
|
||||||
|
else selectedSingles.add(fileKey);
|
||||||
|
return { selectedSingles };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toggleAllSingles: (stagingFiles: ImportStagingFile[]) => {
|
||||||
|
set((state) => ({
|
||||||
|
selectedSingles: (() => {
|
||||||
|
const fileKeys = stagingFiles.map(getStagingFileKey);
|
||||||
|
return state.selectedSingles.size === fileKeys.length &&
|
||||||
|
fileKeys.every((key) => state.selectedSingles.has(key))
|
||||||
|
? new Set<string>()
|
||||||
|
: new Set(fileKeys);
|
||||||
|
})(),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
clearSinglesSelection: () => {
|
||||||
|
set({
|
||||||
|
selectedSingles: new Set<string>(),
|
||||||
|
singlesManualMatches: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
syncSinglesWorkflow: (stagingFiles: ImportStagingFile[]) => {
|
||||||
|
const validKeys = new Set(stagingFiles.map(getStagingFileKey));
|
||||||
|
set((state) => ({
|
||||||
|
selectedSingles: new Set([...state.selectedSingles].filter((key) => validKeys.has(key))),
|
||||||
|
singlesManualMatches: Object.fromEntries(
|
||||||
|
Object.entries(state.singlesManualMatches).filter(([key]) => validKeys.has(key)),
|
||||||
|
),
|
||||||
|
openSingleSearch:
|
||||||
|
state.openSingleSearch && validKeys.has(state.openSingleSearch)
|
||||||
|
? state.openSingleSearch
|
||||||
|
: null,
|
||||||
|
singleSearches: Object.fromEntries(
|
||||||
|
Object.entries(state.singleSearches).filter(([key]) => validKeys.has(key)),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setOpenSingleSearch: (openSingleSearch: string | null) => set({ openSingleSearch }),
|
||||||
|
ensureSingleSearch: (fileKey: string, query: string) => {
|
||||||
|
set((state) => ({
|
||||||
|
singleSearches: {
|
||||||
|
...state.singleSearches,
|
||||||
|
[fileKey]: state.singleSearches[fileKey] ?? {
|
||||||
|
query,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setSingleSearch: (fileKey: string, updater: StateUpdater<SingleSearchState>) => {
|
||||||
|
set((state) => {
|
||||||
|
const current = state.singleSearches[fileKey] ?? {
|
||||||
|
query: '',
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
singleSearches: {
|
||||||
|
...state.singleSearches,
|
||||||
|
[fileKey]: resolveState(current, updater),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectSingleMatch: (fileKey: string, track: ImportTrackResult) => {
|
||||||
|
set((state) => ({
|
||||||
|
singlesManualMatches: { ...state.singlesManualMatches, [fileKey]: track },
|
||||||
|
selectedSingles: new Set(state.selectedSingles).add(fileKey),
|
||||||
|
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,
|
||||||
|
albumSearchLookupSource: state.albumSearchLookupSource,
|
||||||
|
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,
|
||||||
|
setAlbumSearchLookupSource: state.setAlbumSearchLookupSource,
|
||||||
|
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,
|
||||||
|
syncSinglesWorkflow: state.syncSinglesWorkflow,
|
||||||
|
toggleAllSingles: state.toggleAllSingles,
|
||||||
|
toggleSingleInStore: state.toggleSingle,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
243
webui/src/routes/import/-import.types.ts
Normal file
243
webui/src/routes/import/-import.types.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
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<typeof importAutoSearchSchema>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** Provider that returned this result row. */
|
||||||
|
source: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
total_tracks?: number | null;
|
||||||
|
release_date?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
disambiguation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbumSearchPayload {
|
||||||
|
success: boolean;
|
||||||
|
albums?: ImportAlbumResult[];
|
||||||
|
suggestions?: ImportAlbumResult[];
|
||||||
|
/** Provider used to seed the lookup chain for this response. */
|
||||||
|
primary_source?: string | null;
|
||||||
|
ready?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportTrackResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album?: string | null;
|
||||||
|
/** Provider that returned this result row. */
|
||||||
|
source: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportTrackSearchPayload {
|
||||||
|
success: boolean;
|
||||||
|
tracks?: ImportTrackResult[];
|
||||||
|
/** Provider used to seed the lookup chain for this response. */
|
||||||
|
primary_source?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbum {
|
||||||
|
id?: string | number | null;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
/** Provider used to resolve this selected album. */
|
||||||
|
source: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
total_tracks?: number | null;
|
||||||
|
release_date?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
disambiguation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
label?: 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;
|
||||||
414
webui/src/routes/import/-route.test.tsx
Normal file
414
webui/src/routes/import/-route.test.tsx
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
import { createMemoryHistory } from '@tanstack/react-router';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createAppQueryClient } from '@/app/query-client';
|
||||||
|
import { AppRouterProvider, createAppRouter } from '@/app/router';
|
||||||
|
import { HttpResponse, http, server } from '@/test/msw';
|
||||||
|
import { createShellBridge } from '@/test/shell-bridge';
|
||||||
|
|
||||||
|
import type { ImportStagingFile } from './-import.types';
|
||||||
|
|
||||||
|
import { autoImportResultsQueryOptions, autoImportStatusQueryOptions } from './-import.api';
|
||||||
|
import { resetImportWorkflowStore } from './-import.store';
|
||||||
|
|
||||||
|
function renderImportRoute(initialEntries = ['/import']) {
|
||||||
|
const queryClient = createAppQueryClient();
|
||||||
|
const history = createMemoryHistory({ initialEntries });
|
||||||
|
const router = createAppRouter({ history, queryClient });
|
||||||
|
|
||||||
|
return {
|
||||||
|
history,
|
||||||
|
router,
|
||||||
|
queryClient,
|
||||||
|
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFetchUrls() {
|
||||||
|
return vi
|
||||||
|
.mocked(fetch)
|
||||||
|
.mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('import route', () => {
|
||||||
|
let albumMatchBodies: Record<string, unknown>[];
|
||||||
|
let stagingFilesPayload: ImportStagingFile[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
albumMatchBodies = [];
|
||||||
|
stagingFilesPayload = [
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: '02-track.flac',
|
||||||
|
rel_path: 'Album/02-track.flac',
|
||||||
|
full_path: '/music/Staging/Album/02-track.flac',
|
||||||
|
title: 'Track Two',
|
||||||
|
artist: 'Artist A',
|
||||||
|
album: 'Album A',
|
||||||
|
extension: '.flac',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
resetImportWorkflowStore();
|
||||||
|
window.SoulSyncWebShellBridge = createShellBridge();
|
||||||
|
window.showToast = vi.fn();
|
||||||
|
window.showConfirmDialog = vi.fn(async () => true);
|
||||||
|
vi.spyOn(globalThis, 'fetch');
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/import/staging/files', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
staging_path: '/music/Staging',
|
||||||
|
files: stagingFilesPayload,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/import/staging/groups', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
album: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
file_count: 2,
|
||||||
|
file_paths: ['/music/Staging/Album/01-track.flac'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/import/staging/suggestions', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
ready: true,
|
||||||
|
primary_source: 'spotify',
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
id: 'album-1',
|
||||||
|
name: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
source: 'deezer',
|
||||||
|
total_tracks: 1,
|
||||||
|
release_date: '2026-01-01',
|
||||||
|
format: 'CD',
|
||||||
|
country: 'US',
|
||||||
|
disambiguation: '25th Anniversary Edition',
|
||||||
|
status: 'official',
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/import/search/albums', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
primary_source: 'spotify',
|
||||||
|
albums: [
|
||||||
|
{
|
||||||
|
id: 'album-1',
|
||||||
|
name: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
source: 'deezer',
|
||||||
|
total_tracks: 1,
|
||||||
|
release_date: '2026-01-01',
|
||||||
|
format: 'CD',
|
||||||
|
country: 'US',
|
||||||
|
disambiguation: '25th Anniversary Edition',
|
||||||
|
status: 'official',
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.post('/api/import/album/match', async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
albumMatchBodies.push(body);
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
received: body,
|
||||||
|
album: {
|
||||||
|
id: 'album-1',
|
||||||
|
name: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
source: 'deezer',
|
||||||
|
total_tracks: 1,
|
||||||
|
release_date: '2026-01-01',
|
||||||
|
format: 'CD',
|
||||||
|
country: 'US',
|
||||||
|
disambiguation: '25th Anniversary Edition',
|
||||||
|
status: 'official',
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/auto-import/status', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
running: true,
|
||||||
|
current_status: 'idle',
|
||||||
|
active_imports: [],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/auto-import/settings', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
scan_interval: 60,
|
||||||
|
confidence_threshold: 0.9,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/auto-import/results', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/issues/counts', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
counts: {
|
||||||
|
open: 0,
|
||||||
|
in_progress: 0,
|
||||||
|
resolved: 0,
|
||||||
|
dismissed: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
expect(
|
||||||
|
await screen.findByText('1 tracks · 2026 · CD · US · 25th Anniversary Edition'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('official · MusicBrainz')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Showing Deezer results - not from your primary source (Spotify).'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('via Deezer')).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('keeps the import page rendering when staging files fail to load', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/import/staging/files', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Import folder unavailable',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderImportRoute();
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('import-page')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Import Music')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Import folder: error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
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.getByRole('button', { name: /Process Selected\s*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('keeps singles selection tied to file identity across refreshes', async () => {
|
||||||
|
renderImportRoute(['/import/singles']);
|
||||||
|
|
||||||
|
const secondTrack = await screen.findByLabelText('Select 02-track.flac');
|
||||||
|
fireEvent.click(secondTrack);
|
||||||
|
|
||||||
|
stagingFilesPayload = [
|
||||||
|
{
|
||||||
|
filename: '00-intro.flac',
|
||||||
|
rel_path: 'Album/00-intro.flac',
|
||||||
|
full_path: '/music/Staging/Album/00-intro.flac',
|
||||||
|
title: 'Intro',
|
||||||
|
artist: 'Artist A',
|
||||||
|
album: 'Album A',
|
||||||
|
extension: '.flac',
|
||||||
|
},
|
||||||
|
...stagingFilesPayload,
|
||||||
|
];
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('checkbox', { name: 'Select 02-track.flac' })).toBeChecked(),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('checkbox', { name: 'Select 01-track.flac' })).not.toBeChecked();
|
||||||
|
expect(screen.getByRole('button', { name: /Process Selected\s*1/ })).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('surfaces the served source when album search falls back', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/import/search/albums', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
primary_source: 'spotify',
|
||||||
|
albums: [
|
||||||
|
{
|
||||||
|
id: 'album-2',
|
||||||
|
name: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
source: 'musicbrainz',
|
||||||
|
total_tracks: 1,
|
||||||
|
release_date: '2026-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderImportRoute();
|
||||||
|
|
||||||
|
const searchInput = await screen.findByPlaceholderText('Search for an album...');
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'Album A' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Search' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
'Showing MusicBrainz results - not from your primary source (Spotify).',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('via MusicBrainz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders auto-import results from route search state', async () => {
|
||||||
|
renderImportRoute(['/import/auto?autoFilter=pending']);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('Watching')).toHaveAttribute('data-tone', 'success');
|
||||||
|
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(false);
|
||||||
|
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cached auto-import status visible when a refetch fails', async () => {
|
||||||
|
const { queryClient } = renderImportRoute(['/import/auto']);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Watching')).toBeInTheDocument();
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auto-import/status', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Auto-import unavailable',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: autoImportStatusQueryOptions().queryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Watching')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Auto-import is unavailable:/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cached auto-import results visible when a refetch fails', async () => {
|
||||||
|
const { queryClient } = renderImportRoute(['/import/auto?autoFilter=pending']);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auto-import/results', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Auto-import results unavailable',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: autoImportResultsQueryOptions().queryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/Failed to load imports:/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
644
webui/src/routes/import/-ui/album-import-tab.tsx
Normal file
644
webui/src/routes/import/-ui/album-import-tab.tsx
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { type DragEvent, type KeyboardEvent, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button, TextInput } from '@/components/form/form';
|
||||||
|
import { Notice } from '@/components/primitives';
|
||||||
|
|
||||||
|
import type { ImportAlbumResult } from '../-import.types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
importStagingGroupsQueryOptions,
|
||||||
|
importStagingSuggestionsQueryOptions,
|
||||||
|
matchImportAlbum,
|
||||||
|
searchImportAlbums,
|
||||||
|
} from '../-import.api';
|
||||||
|
import {
|
||||||
|
getDisplayedMatchFile,
|
||||||
|
getEffectiveAlbumMatches,
|
||||||
|
getImportSourceBadgeText,
|
||||||
|
getImportSourceFallbackBanner,
|
||||||
|
getTrackDisplayInfo,
|
||||||
|
getUnmatchedStagingFiles,
|
||||||
|
IMPORT_PLACEHOLDER_IMAGE,
|
||||||
|
} from '../-import.helpers';
|
||||||
|
import { useAlbumImportWorkflow } from '../-import.store';
|
||||||
|
import styles from './import-page.module.css';
|
||||||
|
import {
|
||||||
|
fallbackImage,
|
||||||
|
getErrorMessage,
|
||||||
|
useImportQueueActions,
|
||||||
|
useImportStaging,
|
||||||
|
} from './import-shared';
|
||||||
|
|
||||||
|
function useAlbumImportViewModel() {
|
||||||
|
const { refreshStaging, stagingFiles } = useImportStaging();
|
||||||
|
const [dragOverTrack, setDragOverTrack] = useState<number | null>(null);
|
||||||
|
const [tapSelectedChip, setTapSelectedChip] = useState<number | null>(null);
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
...importStagingGroupsQueryOptions(),
|
||||||
|
});
|
||||||
|
const suggestionsQuery = useQuery({
|
||||||
|
...importStagingSuggestionsQueryOptions(),
|
||||||
|
});
|
||||||
|
const { addQueueJob } = useImportQueueActions();
|
||||||
|
const {
|
||||||
|
albumMatch,
|
||||||
|
albumMatchError,
|
||||||
|
albumMatchLoading,
|
||||||
|
albumQuery,
|
||||||
|
albumResults,
|
||||||
|
albumSearchError,
|
||||||
|
albumSearchLoading,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
autoGroupFilePaths,
|
||||||
|
clearAutoGroupFilePaths,
|
||||||
|
matchOverrides,
|
||||||
|
resetAlbumWorkflow,
|
||||||
|
selectedAlbum,
|
||||||
|
setAlbumMatch,
|
||||||
|
setAlbumMatchError,
|
||||||
|
setAlbumMatchLoading,
|
||||||
|
setAlbumQuery,
|
||||||
|
setAlbumResults,
|
||||||
|
setAlbumSearchContext,
|
||||||
|
setAlbumSearchError,
|
||||||
|
setAlbumSearchLoading,
|
||||||
|
setAlbumSearchLookupSource,
|
||||||
|
setMatchOverrides,
|
||||||
|
setSelectedAlbum,
|
||||||
|
} = useAlbumImportWorkflow();
|
||||||
|
|
||||||
|
const resetAlbumSearch = () => {
|
||||||
|
setDragOverTrack(null);
|
||||||
|
setTapSelectedChip(null);
|
||||||
|
resetAlbumWorkflow();
|
||||||
|
void 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 ?? []);
|
||||||
|
setAlbumSearchLookupSource(payload.primary_source ?? null);
|
||||||
|
} catch (error) {
|
||||||
|
setAlbumSearchError(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setAlbumSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAlbum = async (album: ImportAlbumResult) => {
|
||||||
|
setSelectedAlbum(album);
|
||||||
|
setAlbumMatch(null);
|
||||||
|
setAlbumMatchError(null);
|
||||||
|
setAlbumMatchLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass the source that returned this result row so matching keeps using the
|
||||||
|
// same provider even if the search fell back from the lookup source.
|
||||||
|
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,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
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,
|
||||||
|
suggestionsLookupSource: suggestionsQuery.data?.primary_source ?? null,
|
||||||
|
tapSelectedChip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumImportViewModel = ReturnType<typeof useAlbumImportViewModel>;
|
||||||
|
|
||||||
|
type AlbumMetaFields = {
|
||||||
|
total_tracks?: number | null;
|
||||||
|
release_date?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
disambiguation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlbumImportTab() {
|
||||||
|
const viewModel = useAlbumImportViewModel();
|
||||||
|
|
||||||
|
return <AlbumImportPanelContent viewModel={viewModel} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
|
const {
|
||||||
|
albumMatch,
|
||||||
|
albumMatchError,
|
||||||
|
albumMatchLoading,
|
||||||
|
albumQuery,
|
||||||
|
albumResults,
|
||||||
|
albumSearchError,
|
||||||
|
albumSearchLoading,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
groups,
|
||||||
|
onAlbumQueryChange,
|
||||||
|
onBackToSearch,
|
||||||
|
onRunGroupSearch,
|
||||||
|
onRunSearch,
|
||||||
|
onSelectAlbum,
|
||||||
|
selectedAlbum,
|
||||||
|
suggestions,
|
||||||
|
suggestionsReady,
|
||||||
|
suggestionsLookupSource,
|
||||||
|
} = viewModel;
|
||||||
|
|
||||||
|
const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch;
|
||||||
|
const suggestionsFallbackBanner = getImportSourceFallbackBanner(
|
||||||
|
suggestions,
|
||||||
|
suggestionsLookupSource,
|
||||||
|
);
|
||||||
|
const albumResultsFallbackBanner = getImportSourceFallbackBanner(
|
||||||
|
albumResults,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
id="import-page-album-search-section"
|
||||||
|
className={clsx({ [styles.hidden]: showingMatch })}
|
||||||
|
>
|
||||||
|
{albumResults === null && (
|
||||||
|
<>
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div id="import-page-auto-groups" className={styles.importPageAutoGroups}>
|
||||||
|
<div className={styles.importPageSectionLabel}>Auto-Detected Albums</div>
|
||||||
|
<div className={styles.importPageAlbumGrid}>
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<button
|
||||||
|
key={`${group.artist}-${group.album}-${index}`}
|
||||||
|
className={clsx(styles.importPageAlbumCard, styles.importPageAutoGroupCard)}
|
||||||
|
onClick={() => onRunGroupSearch(group)}
|
||||||
|
>
|
||||||
|
<div className={styles.importPageAutoGroupCount}>{group.file_count}</div>
|
||||||
|
<div className={styles.importPageAutoGroupInfo}>
|
||||||
|
<div className={styles.importPageAlbumCardTitle} title={group.album}>
|
||||||
|
{group.album}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageAlbumCardArtist} title={group.artist}>
|
||||||
|
{group.artist} · {group.file_count} tracks
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.importPageSuggestions} id="import-page-suggestions">
|
||||||
|
<div className={styles.importPageSectionLabel}>Suggested from your import folder</div>
|
||||||
|
{suggestionsFallbackBanner ? (
|
||||||
|
<Notice tone="warning">{suggestionsFallbackBanner}</Notice>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.importPageAlbumGrid} id="import-page-suggestions-grid">
|
||||||
|
{suggestions.length > 0 ? (
|
||||||
|
suggestions.map((album) => (
|
||||||
|
<AlbumCard
|
||||||
|
key={`${album.source || 'source'}-${album.id}`}
|
||||||
|
album={album}
|
||||||
|
lookupSource={suggestionsLookupSource}
|
||||||
|
onSelect={onSelectAlbum}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : suggestionsReady ? null : (
|
||||||
|
<div className={styles.importPageEmptyState}>Loading suggestions...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.importPageSearchBar}>
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
id="import-page-album-search-input"
|
||||||
|
className={styles.importPageSearchInput}
|
||||||
|
placeholder="Search for an album..."
|
||||||
|
value={albumQuery}
|
||||||
|
onChange={(event) => onAlbumQueryChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onRunSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={clsx({ [styles.hidden]: albumResults === null })}
|
||||||
|
id="import-page-album-clear-btn"
|
||||||
|
title="Clear search"
|
||||||
|
onClick={onBackToSearch}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onRunSearch}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{albumResultsFallbackBanner ? (
|
||||||
|
<Notice tone="warning">{albumResultsFallbackBanner}</Notice>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.importPageAlbumGrid} id="import-page-album-results">
|
||||||
|
{albumSearchLoading ? (
|
||||||
|
<div className={styles.importPageEmptyState}>Searching...</div>
|
||||||
|
) : albumSearchError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumSearchError}
|
||||||
|
</Notice>
|
||||||
|
) : albumResults?.length === 0 ? (
|
||||||
|
<div className={styles.importPageEmptyState}>No albums found</div>
|
||||||
|
) : (
|
||||||
|
albumResults?.map((album) => (
|
||||||
|
<AlbumCard
|
||||||
|
key={`${album.source || 'source'}-${album.id}`}
|
||||||
|
album={album}
|
||||||
|
lookupSource={albumSearchLookupSource}
|
||||||
|
onSelect={onSelectAlbum}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="import-page-album-match-section"
|
||||||
|
className={clsx({ [styles.hidden]: !showingMatch })}
|
||||||
|
>
|
||||||
|
{albumMatchLoading ? (
|
||||||
|
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
||||||
|
) : albumMatchError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumMatchError}
|
||||||
|
</Notice>
|
||||||
|
) : albumMatch?.album ? (
|
||||||
|
<AlbumMatchPanel viewModel={viewModel} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.importPageEmptyState}>
|
||||||
|
Select an album to start matching files.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumCard({
|
||||||
|
album,
|
||||||
|
lookupSource,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
album: ImportAlbumResult;
|
||||||
|
lookupSource: string | null;
|
||||||
|
onSelect: (album: ImportAlbumResult) => void;
|
||||||
|
}) {
|
||||||
|
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
|
||||||
|
const metaParts = getAlbumMetaParts(album);
|
||||||
|
const detailParts = getAlbumDetailParts(album);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
|
||||||
|
<img
|
||||||
|
src={album.image_url || IMPORT_PLACEHOLDER_IMAGE}
|
||||||
|
alt={album.name}
|
||||||
|
loading="lazy"
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
<div className={styles.importPageAlbumCardTitle} title={album.name}>
|
||||||
|
{album.name}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageAlbumCardArtist} title={album.artist}>
|
||||||
|
{album.artist}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageAlbumCardMeta}>{metaParts.join(' · ')}</div>
|
||||||
|
{detailParts.length > 0 ? (
|
||||||
|
<div className={styles.importPageAlbumCardDetail}>{detailParts.join(' · ')}</div>
|
||||||
|
) : null}
|
||||||
|
{resultSourceBadge ? (
|
||||||
|
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const heroMetaParts = albumMatch?.album ? getAlbumMetaParts(albumMatch.album) : [];
|
||||||
|
|
||||||
|
return albumMatchLoading ? (
|
||||||
|
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
||||||
|
) : albumMatchError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumMatchError}
|
||||||
|
</Notice>
|
||||||
|
) : albumMatch?.album ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.importPageAlbumHero} id="import-page-album-hero">
|
||||||
|
<img
|
||||||
|
src={albumMatch.album.image_url || IMPORT_PLACEHOLDER_IMAGE}
|
||||||
|
alt={albumMatch.album.name}
|
||||||
|
loading="lazy"
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
<div className={styles.importPageAlbumHeroInfo}>
|
||||||
|
<div className={styles.importPageAlbumHeroTitle}>{albumMatch.album.name}</div>
|
||||||
|
<div className={styles.importPageAlbumHeroArtist}>{albumMatch.album.artist}</div>
|
||||||
|
<div className={styles.importPageAlbumHeroMeta}>{heroMetaParts.join(' · ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importPageMatchHeader}>
|
||||||
|
<h3>Track Matching</h3>
|
||||||
|
<div className={styles.importPageMatchActions}>
|
||||||
|
<Button variant="secondary" onClick={onAutoRematch}>
|
||||||
|
Re-match Automatically
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onBackToSearch}>
|
||||||
|
Back to Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importPageMatchList} id="import-page-match-list">
|
||||||
|
{(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 (
|
||||||
|
<div
|
||||||
|
key={`${trackInfo.displayTrackNumber}-${trackInfo.name}-${index}`}
|
||||||
|
className={clsx(styles.importPageMatchRow, {
|
||||||
|
[styles.matched]: file,
|
||||||
|
[styles.dragOver]: dragOverTrack === index,
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.importPageMatchNum}>{trackInfo.displayTrackNumber}</span>
|
||||||
|
<span className={styles.importPageMatchTrack}>{trackInfo.name}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(styles.importPageMatchFile, {
|
||||||
|
[styles.hasFile]: file,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.importPageMatchFileName}>{file.filename}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(styles.importPageMatchConfidence, {
|
||||||
|
[styles.low]: confidence < 0.7,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{confidencePercent}%
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={styles.importPageMatchDropZone}>Drop a file here</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{file ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onUnmatchTrack(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importPageUnmatchedPool} id="import-page-unmatched-pool">
|
||||||
|
<div className={styles.importPagePoolLabel}>
|
||||||
|
Unmatched Files (<span id="import-page-unmatched-count">{unmatchedFiles.length}</span>)
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPagePoolChips} id="import-page-pool-chips">
|
||||||
|
{unmatchedFiles.length === 0 ? (
|
||||||
|
<span className={styles.importPagePoolEmpty}>All files matched</span>
|
||||||
|
) : (
|
||||||
|
unmatchedFiles.map(({ file, index }) => (
|
||||||
|
<span
|
||||||
|
key={`${file.full_path}-${index}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={clsx(styles.importPageFileChip, {
|
||||||
|
[styles.selected]: tapSelectedChip === index,
|
||||||
|
})}
|
||||||
|
draggable
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onTapSelectChip(index);
|
||||||
|
}}
|
||||||
|
onDragStart={(event: DragEvent<HTMLSpanElement>) => {
|
||||||
|
event.dataTransfer.setData('text/plain', String(index));
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
|
onKeyDown={(event: KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onTapSelectChip(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.filename}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importPageMatchFooter}>
|
||||||
|
<div className={styles.importPageMatchStats} id="import-page-match-stats">
|
||||||
|
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
id="import-page-album-process-btn"
|
||||||
|
disabled={matchedCount === 0}
|
||||||
|
onClick={onProcessAlbum}
|
||||||
|
>
|
||||||
|
Process {matchedCount} Track{matchedCount === 1 ? '' : 's'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.importPageEmptyState}>Select an album to start matching files.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlbumMetaParts(album: AlbumMetaFields) {
|
||||||
|
return [
|
||||||
|
`${album.total_tracks || 0} tracks`,
|
||||||
|
album.release_date?.substring(0, 4) || '',
|
||||||
|
album.format || '',
|
||||||
|
album.country || '',
|
||||||
|
album.disambiguation || '',
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlbumDetailParts(album: AlbumMetaFields) {
|
||||||
|
return [album.status || '', album.label || ''].filter(Boolean);
|
||||||
|
}
|
||||||
614
webui/src/routes/import/-ui/auto-import-tab.tsx
Normal file
614
webui/src/routes/import/-ui/auto-import-tab.tsx
Normal file
|
|
@ -0,0 +1,614 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
OptionButton,
|
||||||
|
OptionButtonGroup,
|
||||||
|
RangeInput,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
} from '@/components/form/form';
|
||||||
|
import { Badge, Notice } from '@/components/primitives';
|
||||||
|
|
||||||
|
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,
|
||||||
|
getAutoImportStatusMeta,
|
||||||
|
getAutoImportStatusText,
|
||||||
|
getAutoImportStatusTone,
|
||||||
|
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<Set<number>>(() => 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 statusTone = getAutoImportStatusTone(statusQuery.data);
|
||||||
|
const statusError =
|
||||||
|
statusQuery.error && !statusQuery.data ? getErrorMessage(statusQuery.error) : '';
|
||||||
|
const resultsError =
|
||||||
|
resultsQuery.error && !resultsQuery.data ? getErrorMessage(resultsQuery.error) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{statusError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Auto-import is unavailable: {statusError}
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.autoImportControls}>
|
||||||
|
<div className={styles.autoImportToggleRow}>
|
||||||
|
<div className={styles.autoImportToggleLabel}>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(statusQuery.data?.running)}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
aria-labelledby="auto-import-toggle-label"
|
||||||
|
id="auto-import-enabled"
|
||||||
|
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
|
||||||
|
/>
|
||||||
|
<span id="auto-import-toggle-label">Auto-Import</span>
|
||||||
|
</div>
|
||||||
|
<Badge id="auto-import-status-text" tone={statusTone}>
|
||||||
|
{getAutoImportStatusText(statusQuery.data)}
|
||||||
|
</Badge>
|
||||||
|
<div className={styles.importPageFlexSpacer} />
|
||||||
|
{statusQuery.data?.running ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
id="auto-import-scan-now"
|
||||||
|
title="Scan import folder now"
|
||||||
|
disabled={scanMutation.isPending}
|
||||||
|
onClick={() => scanMutation.mutate()}
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
Scan Now
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{statusQuery.data?.running ? (
|
||||||
|
<div className={styles.autoImportSettingsRow} id="auto-import-settings-row">
|
||||||
|
<div className={styles.autoImportSetting}>
|
||||||
|
<span>Confidence:</span>
|
||||||
|
<RangeInput
|
||||||
|
label="Confidence"
|
||||||
|
min={50}
|
||||||
|
max={100}
|
||||||
|
value={confidence}
|
||||||
|
onValueChange={setConfidence}
|
||||||
|
/>
|
||||||
|
<span className={styles.autoImportConfidenceValue} id="auto-import-conf-val">
|
||||||
|
{confidence}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportSetting}>
|
||||||
|
<span>Interval:</span>
|
||||||
|
<Select
|
||||||
|
id="auto-import-interval"
|
||||||
|
size="sm"
|
||||||
|
value={interval}
|
||||||
|
onChange={(event) => setInterval(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
<option value="30">30s</option>
|
||||||
|
<option value="60">60s</option>
|
||||||
|
<option value="120">2m</option>
|
||||||
|
<option value="300">5m</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={saveSettingsMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
saveSettingsMutation.mutate({
|
||||||
|
confidenceThreshold: confidence / 100,
|
||||||
|
scanInterval: interval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeLines.length > 0 ? (
|
||||||
|
<div className={styles.autoImportProgress} id="auto-import-progress">
|
||||||
|
<div className={styles.autoImportProgressText} id="auto-import-progress-text">
|
||||||
|
{activeLines.length === 1
|
||||||
|
? `Processing ${activeLines[0]}`
|
||||||
|
: `Processing ${activeLines.length} imports:`}
|
||||||
|
{activeLines.length > 1
|
||||||
|
? activeLines.map((line) => <div key={line}>{line}</div>)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportProgressBar}>
|
||||||
|
<div className={styles.autoImportProgressFill} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allResults.length > 0 ? (
|
||||||
|
<OptionButtonGroup size="sm" className={styles.autoImportFilters}>
|
||||||
|
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
|
||||||
|
<OptionButton
|
||||||
|
key={filter}
|
||||||
|
selected={autoFilter === filter}
|
||||||
|
variant={autoFilter === filter ? 'default' : 'ghost'}
|
||||||
|
onClick={() => onFilterChange(filter)}
|
||||||
|
>
|
||||||
|
<span>{getAutoImportFilterLabel(filter)}</span>
|
||||||
|
<Badge tone={getAutoImportFilterTone(filter)}>
|
||||||
|
{getAutoImportFilterCount(filter, counts, allResults.length)}
|
||||||
|
</Badge>
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
<div className={styles.importPageFlexSpacer} />
|
||||||
|
{counts.review > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
id="auto-import-approve-all"
|
||||||
|
disabled={approveAllMutation.isPending}
|
||||||
|
onClick={() => approveAllMutation.mutate()}
|
||||||
|
>
|
||||||
|
Approve All
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{counts.imported + counts.failed > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
id="auto-import-clear-completed"
|
||||||
|
disabled={clearMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => clearMutation.mutate()}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</OptionButtonGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.autoImportResults} id="auto-import-results">
|
||||||
|
{resultsError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Failed to load imports: {resultsError}
|
||||||
|
</Notice>
|
||||||
|
) : allResults.length === 0 ? (
|
||||||
|
<div className={styles.autoImportEmpty}>
|
||||||
|
<p>No imports yet. Drop album folders or single tracks into your import folder.</p>
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className={styles.autoImportEmpty}>
|
||||||
|
<p>No {autoFilter === 'pending' ? 'pending review' : autoFilter} items.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
results.map((result, index) => (
|
||||||
|
<AutoImportResultCard
|
||||||
|
key={result.id}
|
||||||
|
expanded={expandedRows.has(result.id)}
|
||||||
|
index={index}
|
||||||
|
approvePending={approveMutation.isPending}
|
||||||
|
rejectPending={rejectMutation.isPending}
|
||||||
|
result={result}
|
||||||
|
status={statusQuery.data}
|
||||||
|
onApprove={() => 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;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.autoImportCard, statusCardClass)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onToggle}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggle();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.autoImportCardTop}>
|
||||||
|
<div className={styles.autoImportCardLeft}>
|
||||||
|
{result.image_url ? (
|
||||||
|
<img
|
||||||
|
className={styles.autoImportCardArt}
|
||||||
|
src={result.image_url}
|
||||||
|
alt=""
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.autoImportCardArtFallback}>💿</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardCenter}>
|
||||||
|
<div className={styles.autoImportCardAlbum}>
|
||||||
|
{result.album_name || result.folder_name}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardArtist}>
|
||||||
|
{result.artist_name || 'Unknown Artist'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardMeta}>
|
||||||
|
<span>{matchSummary}</span>
|
||||||
|
{methodLabel ? (
|
||||||
|
<span className={styles.autoImportMethodBadge}>{methodLabel}</span>
|
||||||
|
) : null}
|
||||||
|
{timeAgo ? <span>{timeAgo}</span> : null}
|
||||||
|
</div>
|
||||||
|
{result.error_message ? (
|
||||||
|
<div className={styles.autoImportCardError}>{result.error_message}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardRight}>
|
||||||
|
<div className={clsx(styles.autoImportStatusBadge, statusBadgeClass)}>
|
||||||
|
{statusMeta.icon} {statusMeta.label}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportConfidenceBar}>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.autoImportConfidenceFill, confidenceFillClass)}
|
||||||
|
style={{ width: `${confidencePercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportConfidenceText}>{confidencePercent}% confidence</div>
|
||||||
|
{result.status === 'pending_review' ? (
|
||||||
|
<div className={styles.autoImportActions}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={approvePending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onApprove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Approve & Import
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={rejectPending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onReject();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardFolderPath}>{result.folder_name}</div>
|
||||||
|
{trackDetails.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.autoImportTrackList, {
|
||||||
|
[styles.expanded]: expanded,
|
||||||
|
})}
|
||||||
|
id={`auto-import-tracks-${index}`}
|
||||||
|
>
|
||||||
|
<div className={styles.autoImportTrackListHeader}>
|
||||||
|
<span>Track</span>
|
||||||
|
<span>Matched File</span>
|
||||||
|
<span>Conf</span>
|
||||||
|
</div>
|
||||||
|
{trackDetails.map((track, trackIndex) => {
|
||||||
|
const rowClassName = clsx(styles.autoImportTrackRow, {
|
||||||
|
[styles.autoImportTrackRowActive]:
|
||||||
|
isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 === liveTrackIndex,
|
||||||
|
[styles.autoImportTrackRowDone]:
|
||||||
|
isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 < liveTrackIndex,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div key={`${track.name}-${track.file}-${trackIndex}`} className={rowClassName}>
|
||||||
|
<span className={styles.autoImportTrackName}>{track.name}</span>
|
||||||
|
<span className={styles.autoImportTrackFile}>{track.file}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
styles.autoImportTrackConf,
|
||||||
|
getAutoImportConfidenceClass(getConfidenceClass(track.confidence)),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{track.confidence}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMutationError(error: unknown) {
|
||||||
|
window.showToast?.(getErrorMessage(error), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportCardClass(status: string): string {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
completed: styles.autoImportCompleted,
|
||||||
|
review: styles.autoImportReview,
|
||||||
|
failed: styles.autoImportFailed,
|
||||||
|
processing: styles.autoImportProcessing,
|
||||||
|
};
|
||||||
|
|
||||||
|
return classes[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportBadgeClass(status: string): string {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
high: styles.autoImportConfHigh,
|
||||||
|
medium: styles.autoImportConfMedium,
|
||||||
|
low: styles.autoImportConfLow,
|
||||||
|
};
|
||||||
|
|
||||||
|
return classes[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportFilterLabel(filter: ImportAutoFilter): string {
|
||||||
|
switch (filter) {
|
||||||
|
case 'all':
|
||||||
|
return 'All';
|
||||||
|
case 'pending':
|
||||||
|
return 'Needs Review';
|
||||||
|
case 'imported':
|
||||||
|
return 'Imported';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportFilterCount(
|
||||||
|
filter: ImportAutoFilter,
|
||||||
|
counts: ReturnType<typeof getAutoImportCounts>,
|
||||||
|
totalCount: number,
|
||||||
|
): number {
|
||||||
|
switch (filter) {
|
||||||
|
case 'all':
|
||||||
|
return totalCount;
|
||||||
|
case 'pending':
|
||||||
|
return counts.review;
|
||||||
|
case 'imported':
|
||||||
|
return counts.imported;
|
||||||
|
case 'failed':
|
||||||
|
return counts.failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportFilterTone(
|
||||||
|
filter: ImportAutoFilter,
|
||||||
|
): 'neutral' | 'warning' | 'success' | 'danger' {
|
||||||
|
switch (filter) {
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'imported':
|
||||||
|
return 'success';
|
||||||
|
case 'failed':
|
||||||
|
return 'danger';
|
||||||
|
case 'all':
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMethodLabel(method: string | null | undefined): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
tags: 'Tags',
|
||||||
|
folder_name: 'Folder Name',
|
||||||
|
acoustid: 'AcoustID',
|
||||||
|
filename: 'Filename',
|
||||||
|
};
|
||||||
|
return method ? labels[method] || method : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAction({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (window.showConfirmDialog) {
|
||||||
|
return await window.showConfirmDialog({ title, message, confirmText });
|
||||||
|
}
|
||||||
|
return window.confirm(message);
|
||||||
|
}
|
||||||
1825
webui/src/routes/import/-ui/import-page.module.css
Normal file
1825
webui/src/routes/import/-ui/import-page.module.css
Normal file
File diff suppressed because it is too large
Load diff
208
webui/src/routes/import/-ui/import-page.tsx
Normal file
208
webui/src/routes/import/-ui/import-page.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { Link, Outlet } from '@tanstack/react-router';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { Button } from '@/components/form/form';
|
||||||
|
import { Show } from '@/components/primitives';
|
||||||
|
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
||||||
|
|
||||||
|
import type { ImportQueueEntry } from '../-import.types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getQueueProgressPercent,
|
||||||
|
getQueueStatusText,
|
||||||
|
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 (
|
||||||
|
<div id="import-page" data-testid="import-page">
|
||||||
|
<div className={styles.importPageContainer}>
|
||||||
|
<ImportHeader
|
||||||
|
error={stagingQuery.error}
|
||||||
|
fileCountText={getStagingStatsText(stagingFiles)}
|
||||||
|
loading={stagingQuery.isLoading}
|
||||||
|
stagingPath={stagingPath}
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
lastRefreshedAt={lastRefreshedAt}
|
||||||
|
onRefresh={refreshStaging}
|
||||||
|
/>
|
||||||
|
<ImportProcessingQueue />
|
||||||
|
<ImportTabNav />
|
||||||
|
<section className={clsx(styles.importPageTabContent, styles.active)}>
|
||||||
|
<Outlet />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<header className={styles.importPageHeader}>
|
||||||
|
<div className={styles.importPageTitleRow}>
|
||||||
|
<h1 className={styles.importPageTitle}>
|
||||||
|
<img src="/static/import.png" className="page-header-icon" alt="" />
|
||||||
|
<span>Import Music</span>
|
||||||
|
</h1>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
title="Re-scan import folder"
|
||||||
|
aria-busy={refreshing}
|
||||||
|
disabled={refreshing}
|
||||||
|
onClick={onRefresh}
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageStagingBar} id="import-staging-bar">
|
||||||
|
<span className={styles.importStagingPath} id="import-page-staging-path">
|
||||||
|
{error ? 'Import folder: error' : `Import: ${stagingPath}`}
|
||||||
|
</span>
|
||||||
|
<Show when={lastRefreshedAt != null}>
|
||||||
|
<span className={styles.importStagingRefreshAt}>
|
||||||
|
{lastRefreshedAt ? `Last refreshed: ${lastRefreshedAt}` : null}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span className={styles.importStagingStats} id="import-page-staging-stats">
|
||||||
|
{loading ? 'loading...' : fileCountText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportProcessingQueue() {
|
||||||
|
const { clearFinishedJobs, queue } = useImportQueueWorkflow();
|
||||||
|
const hasFinished = queue.some((entry) => entry.status !== 'running');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={clsx(styles.importPageQueue, {
|
||||||
|
[styles.hidden]: queue.length === 0,
|
||||||
|
})}
|
||||||
|
id="import-page-queue"
|
||||||
|
>
|
||||||
|
<div className={styles.importPageQueueHeader}>
|
||||||
|
<span className={styles.importPageQueueTitle}>Processing</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
id="import-page-queue-clear"
|
||||||
|
style={{ display: hasFinished ? undefined : 'none' }}
|
||||||
|
onClick={clearFinishedJobs}
|
||||||
|
>
|
||||||
|
Clear finished
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageQueueList} id="import-page-queue-list">
|
||||||
|
{queue.map((entry) => (
|
||||||
|
<ImportQueueItem key={entry.id} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) {
|
||||||
|
const statusText = getQueueStatusText(entry);
|
||||||
|
const statusClass = clsx({
|
||||||
|
[styles.error]:
|
||||||
|
entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0),
|
||||||
|
[styles.done]: entry.status === 'done',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.importPageQueueItem}>
|
||||||
|
{entry.imageUrl ? (
|
||||||
|
<img
|
||||||
|
className={styles.importPageQueueArt}
|
||||||
|
src={entry.imageUrl}
|
||||||
|
alt=""
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={clsx(styles.importPageQueueArt, styles.importPageQueueArtEmpty)}>♪</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.importPageQueueInfo}>
|
||||||
|
<div className={styles.importPageQueueName}>{entry.label}</div>
|
||||||
|
<div className={styles.importPageQueueDetail}>{entry.sublabel}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageQueueProgress}>
|
||||||
|
<div className={styles.importPageQueueBar}>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.importPageQueueFill, {
|
||||||
|
[styles.error]: entry.status === 'error',
|
||||||
|
})}
|
||||||
|
style={{ width: `${getQueueProgressPercent(entry)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={clsx(styles.importPageQueueStatus, statusClass)}>{statusText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportTabNav() {
|
||||||
|
return (
|
||||||
|
<nav className={styles.importPageTabBar} aria-label="Import modes">
|
||||||
|
<Link
|
||||||
|
to="/import/auto"
|
||||||
|
className={styles.importPageTab}
|
||||||
|
activeProps={{ className: clsx(styles.importPageTab, styles.active) }}
|
||||||
|
id="import-page-tab-auto"
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/import/album"
|
||||||
|
className={styles.importPageTab}
|
||||||
|
activeProps={{ className: clsx(styles.importPageTab, styles.active) }}
|
||||||
|
id="import-page-tab-album"
|
||||||
|
>
|
||||||
|
Albums
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/import/singles"
|
||||||
|
className={styles.importPageTab}
|
||||||
|
activeProps={{ className: clsx(styles.importPageTab, styles.active) }}
|
||||||
|
id="import-page-tab-singles"
|
||||||
|
>
|
||||||
|
Singles
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
webui/src/routes/import/-ui/import-shared.tsx
Normal file
109
webui/src/routes/import/-ui/import-shared.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import type { ImportQueueJob, ImportStagingFile } 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';
|
||||||
|
|
||||||
|
const EMPTY_STAGING_FILES: ImportStagingFile[] = [];
|
||||||
|
|
||||||
|
export function useImportStaging() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs);
|
||||||
|
const stagingQuery = useQuery({
|
||||||
|
...importStagingFilesQueryOptions(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
refreshStaging: async () => {
|
||||||
|
clearFinishedJobs();
|
||||||
|
await invalidateImportStagingQueries(queryClient);
|
||||||
|
},
|
||||||
|
// Keep the empty fallback stable so staging-driven effects do not loop while loading.
|
||||||
|
stagingFiles: stagingQuery.data?.files ?? EMPTY_STAGING_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 (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
336
webui/src/routes/import/-ui/singles-import-tab.tsx
Normal file
336
webui/src/routes/import/-ui/singles-import-tab.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { Button, Checkbox, TextInput } from '@/components/form/form';
|
||||||
|
import { Badge, Notice } from '@/components/primitives';
|
||||||
|
|
||||||
|
import type { SingleSearchState } from '../-import.store';
|
||||||
|
import type { ImportTrackResult } from '../-import.types';
|
||||||
|
import type { ImportStagingFile } from '../-import.types';
|
||||||
|
|
||||||
|
import { searchImportTracks } from '../-import.api';
|
||||||
|
import { formatDuration, getStagingFileKey } from '../-import.helpers';
|
||||||
|
import { useSinglesImportWorkflow } from '../-import.store';
|
||||||
|
import styles from './import-page.module.css';
|
||||||
|
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,
|
||||||
|
syncSinglesWorkflow,
|
||||||
|
toggleAllSingles,
|
||||||
|
toggleSingleInStore,
|
||||||
|
} = useSinglesImportWorkflow();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncSinglesWorkflow(stagingFiles);
|
||||||
|
}, [stagingFiles, syncSinglesWorkflow]);
|
||||||
|
|
||||||
|
const openSingleSearchPanel = (file: ImportStagingFile) => {
|
||||||
|
const fileKey = getStagingFileKey(file);
|
||||||
|
if (openSingleSearch === fileKey) {
|
||||||
|
setOpenSingleSearch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenSingleSearch(fileKey);
|
||||||
|
const defaultQuery =
|
||||||
|
[file?.artist, file?.title].filter(Boolean).join(' ') ||
|
||||||
|
(file?.filename || '').replace(/\.[^.]+$/, '');
|
||||||
|
ensureSingleSearch(fileKey, defaultQuery);
|
||||||
|
if (defaultQuery && !singleSearches[fileKey]?.results.length) {
|
||||||
|
void runSingleSearch(fileKey, defaultQuery);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSingleSearch = async (fileKey: string, query: string) => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
setSingleSearch(fileKey, (current) => ({
|
||||||
|
query: trimmed,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
results: current.results,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await searchImportTracks(trimmed);
|
||||||
|
setSingleSearch(fileKey, {
|
||||||
|
query: trimmed,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
results: payload.tracks ?? [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setSingleSearch(fileKey, {
|
||||||
|
query: trimmed,
|
||||||
|
loading: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSingleMatch = (fileKey: string, track: ImportTrackResult) => {
|
||||||
|
selectSingleMatchInStore(fileKey, track);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processSingles = () => {
|
||||||
|
const filesToProcess = stagingFiles.flatMap((file) => {
|
||||||
|
const fileKey = getStagingFileKey(file);
|
||||||
|
if (!selectedSingles.has(fileKey)) return [];
|
||||||
|
const manualMatch = singlesManualMatches[fileKey];
|
||||||
|
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();
|
||||||
|
void refreshStaging();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SinglesImportPanel
|
||||||
|
files={stagingFiles}
|
||||||
|
manualMatches={singlesManualMatches}
|
||||||
|
openSearchKey={openSingleSearch}
|
||||||
|
searchStates={singleSearches}
|
||||||
|
selected={selectedSingles}
|
||||||
|
onOpenSearch={openSingleSearchPanel}
|
||||||
|
onProcessSingles={processSingles}
|
||||||
|
onRunSearch={runSingleSearch}
|
||||||
|
onSearchQueryChange={(fileKey, query) => {
|
||||||
|
setSingleSearch(fileKey, (current) => ({
|
||||||
|
query,
|
||||||
|
loading: current.loading,
|
||||||
|
error: current.error,
|
||||||
|
results: current.results,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onSelectAll={() => toggleAllSingles(stagingFiles)}
|
||||||
|
onSelectMatch={selectSingleMatch}
|
||||||
|
onToggleSingle={toggleSingleInStore}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SinglesImportPanel({
|
||||||
|
files,
|
||||||
|
manualMatches,
|
||||||
|
openSearchKey,
|
||||||
|
searchStates,
|
||||||
|
selected,
|
||||||
|
onOpenSearch,
|
||||||
|
onProcessSingles,
|
||||||
|
onRunSearch,
|
||||||
|
onSearchQueryChange,
|
||||||
|
onSelectAll,
|
||||||
|
onSelectMatch,
|
||||||
|
onToggleSingle,
|
||||||
|
}: {
|
||||||
|
files: ImportStagingFile[];
|
||||||
|
manualMatches: Record<string, ImportTrackResult>;
|
||||||
|
openSearchKey: string | null;
|
||||||
|
searchStates: Record<string, SingleSearchState>;
|
||||||
|
selected: Set<string>;
|
||||||
|
onOpenSearch: (file: ImportStagingFile) => void;
|
||||||
|
onProcessSingles: () => void;
|
||||||
|
onRunSearch: (fileKey: string, query: string) => void;
|
||||||
|
onSearchQueryChange: (fileKey: string, query: string) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onSelectMatch: (fileKey: string, track: ImportTrackResult) => void;
|
||||||
|
onToggleSingle: (fileKey: string) => void;
|
||||||
|
}) {
|
||||||
|
const selectedCount = files.filter((file) => selected.has(getStagingFileKey(file))).length;
|
||||||
|
const allSelected = files.length > 0 && selectedCount === files.length;
|
||||||
|
const processVariant = selectedCount > 0 ? 'primary' : 'secondary';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.importPageSinglesHeader}>
|
||||||
|
<div className={styles.importPageSinglesActions}>
|
||||||
|
<Button variant="secondary" onClick={onSelectAll}>
|
||||||
|
<span id="import-page-select-all-text">
|
||||||
|
{allSelected ? 'Deselect All' : 'Select All'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={processVariant}
|
||||||
|
id="import-page-singles-process-btn"
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
onClick={onProcessSingles}
|
||||||
|
>
|
||||||
|
<span>Process Selected</span>
|
||||||
|
<Badge>{selectedCount}</Badge>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageSinglesList} id="import-page-singles-list">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className={styles.importPageEmptyState}>No audio files found in import folder</div>
|
||||||
|
) : (
|
||||||
|
files.map((file) => {
|
||||||
|
const fileKey = getStagingFileKey(file);
|
||||||
|
const manualMatch = manualMatches[fileKey];
|
||||||
|
const isSelected = selected.has(fileKey);
|
||||||
|
const searchState = searchStates[fileKey];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={fileKey}
|
||||||
|
className={clsx(styles.importPageSingleItem, {
|
||||||
|
[styles.matched]: manualMatch,
|
||||||
|
})}
|
||||||
|
data-single-key={fileKey}
|
||||||
|
>
|
||||||
|
<label className={styles.importPageSingleCheckboxWrap}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
aria-label={`Select ${file.filename}`}
|
||||||
|
onCheckedChange={() => onToggleSingle(fileKey)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={styles.importPageSingleInfo}>
|
||||||
|
<div className={styles.importPageSingleFilename}>{file.filename}</div>
|
||||||
|
<div className={styles.importPageSingleMeta}>
|
||||||
|
{file.title ? <span>{file.title}</span> : null}
|
||||||
|
{file.artist ? <span>{file.artist}</span> : null}
|
||||||
|
{file.extension ? <span>{file.extension}</span> : null}
|
||||||
|
</div>
|
||||||
|
{manualMatch ? (
|
||||||
|
<div className={styles.importPageSingleMatchedInfo}>
|
||||||
|
✓ {manualMatch.name} - {manualMatch.artist}
|
||||||
|
<button
|
||||||
|
className={styles.importPageSingleMatchedChange}
|
||||||
|
data-import-page-inline-action
|
||||||
|
onClick={() => onOpenSearch(file)}
|
||||||
|
>
|
||||||
|
change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageSingleActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onOpenSearch(file)}>
|
||||||
|
🔍 Identify
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{openSearchKey === fileKey ? (
|
||||||
|
<SingleSearchPanel
|
||||||
|
fileKey={fileKey}
|
||||||
|
searchState={searchState}
|
||||||
|
onQueryChange={onSearchQueryChange}
|
||||||
|
onRunSearch={onRunSearch}
|
||||||
|
onSelectMatch={onSelectMatch}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleSearchPanel({
|
||||||
|
fileKey,
|
||||||
|
searchState,
|
||||||
|
onQueryChange,
|
||||||
|
onRunSearch,
|
||||||
|
onSelectMatch,
|
||||||
|
}: {
|
||||||
|
fileKey: string;
|
||||||
|
searchState: SingleSearchState | undefined;
|
||||||
|
onQueryChange: (fileKey: string, query: string) => void;
|
||||||
|
onRunSearch: (fileKey: string, query: string) => void;
|
||||||
|
onSelectMatch: (fileKey: string, track: ImportTrackResult) => void;
|
||||||
|
}) {
|
||||||
|
const query = searchState?.query ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.importPageSingleSearchPanel}>
|
||||||
|
<div className={styles.importPageSingleSearchBar}>
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
className={styles.importPageSingleSearchInput}
|
||||||
|
value={query}
|
||||||
|
placeholder="Search artist - title..."
|
||||||
|
onChange={(event) => onQueryChange(fileKey, event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onRunSearch(fileKey, query);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" onClick={() => onRunSearch(fileKey, query)}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageSingleSearchResults}>
|
||||||
|
{searchState?.loading ? (
|
||||||
|
<div className={styles.importPageEmptyState}>Searching...</div>
|
||||||
|
) : searchState?.error ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {searchState.error}
|
||||||
|
</Notice>
|
||||||
|
) : searchState?.results.length === 0 ? (
|
||||||
|
<div className={styles.importPageEmptyState}>No results found</div>
|
||||||
|
) : (
|
||||||
|
searchState?.results.map((track, index) => (
|
||||||
|
<button
|
||||||
|
key={`${track.source || 'source'}-${track.id}-${index}`}
|
||||||
|
className={styles.importPageSingleResultItem}
|
||||||
|
data-import-page-result-row
|
||||||
|
onClick={() => onSelectMatch(fileKey, track)}
|
||||||
|
>
|
||||||
|
{track.image_url ? (
|
||||||
|
<img
|
||||||
|
className={styles.importPageSingleResultImg}
|
||||||
|
src={track.image_url}
|
||||||
|
alt=""
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.importPageSingleResultInfo}>
|
||||||
|
<div className={styles.importPageSingleResultName}>
|
||||||
|
{track.name} - {track.artist}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageSingleResultDetail}>
|
||||||
|
{track.album}
|
||||||
|
{track.duration_ms ? ` - ${formatDuration(track.duration_ms)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.importPageSingleResultSelect}>Select</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
webui/src/routes/import/album.tsx
Normal file
15
webui/src/routes/import/album.tsx
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
27
webui/src/routes/import/auto.tsx
Normal file
27
webui/src/routes/import/auto.tsx
Normal file
|
|
@ -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 <AutoImportPanel autoFilter={autoFilter} onFilterChange={setAutoFilter} />;
|
||||||
|
}
|
||||||
7
webui/src/routes/import/index.tsx
Normal file
7
webui/src/routes/import/index.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/import/')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/import/album', replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
22
webui/src/routes/import/route.tsx
Normal file
22
webui/src/routes/import/route.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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: ({ context }) => {
|
||||||
|
// Warm the staging query if possible, but never block the route on a transient fetch
|
||||||
|
// failure. The page owns the in-place error state for that case.
|
||||||
|
void context.queryClient.prefetchQuery(importStagingFilesQueryOptions());
|
||||||
|
},
|
||||||
|
component: ImportPage,
|
||||||
|
});
|
||||||
7
webui/src/routes/import/singles.tsx
Normal file
7
webui/src/routes/import/singles.tsx
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
@ -2420,9 +2420,6 @@ async function loadPageData(pageId) {
|
||||||
loadApiKeys();
|
loadApiKeys();
|
||||||
loadBlacklistCount();
|
loadBlacklistCount();
|
||||||
break;
|
break;
|
||||||
case 'import':
|
|
||||||
initializeImportPage();
|
|
||||||
break;
|
|
||||||
case 'hydrabase':
|
case 'hydrabase':
|
||||||
// Check connection status and pre-fill saved credentials
|
// Check connection status and pre-fill saved credentials
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1834,7 +1834,7 @@
|
||||||
/* --- Phase 9: Touch & Hover Adaptations --- */
|
/* --- Phase 9: Touch & Hover Adaptations --- */
|
||||||
|
|
||||||
/* Global touch targets - only standalone/action buttons, not inline */
|
/* Global touch targets - only standalone/action buttons, not inline */
|
||||||
button:not(.watchlist-card-remove):not(.wishlist-delete-btn):not(.wishlist-delete-album-btn):not(.wishlist-delete-btn-small):not(.wishlist-back-btn):not(.alphabet-btn):not(.filter-btn):not(.playlist-modal-close) {
|
button:not(.watchlist-card-remove):not(.wishlist-delete-btn):not(.wishlist-delete-album-btn):not(.wishlist-delete-btn-small):not(.wishlist-back-btn):not(.alphabet-btn):not(.filter-btn):not(.playlist-modal-close):not([data-import-page-result-row]):not([data-import-page-inline-action]) {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2312,86 +2312,9 @@
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.1s ease;
|
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) {
|
@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 - Mobile */
|
||||||
.profile-picker-grid {
|
.profile-picker-grid {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
|
||||||
|
|
@ -1190,9 +1190,7 @@ async function loadInitialData() {
|
||||||
if (route?.kind === 'react') {
|
if (route?.kind === 'react') {
|
||||||
showReactHost(targetPage);
|
showReactHost(targetPage);
|
||||||
setActivePageChrome(targetPage);
|
setActivePageChrome(targetPage);
|
||||||
if (window.location.pathname !== route.path) {
|
// Keep nested react-tab URLs like /import/auto or /import/singles intact.
|
||||||
history.replaceState({ page: targetPage }, '', route.path);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -60,6 +60,10 @@ function expectedUrlPattern(path: string, pageId: ShellPageId): RegExp {
|
||||||
return /\/stats(?:\?range=7d)?$/;
|
return /\/stats(?:\?range=7d)?$/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageId === 'import') {
|
||||||
|
return /\/import\/album$/;
|
||||||
|
}
|
||||||
|
|
||||||
return new RegExp(`${path.replace('/', '\\/')}$`);
|
return new RegExp(`${path.replace('/', '\\/')}$`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue