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,
|
||||
"total_tracks": album.total_tracks,
|
||||
"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 {},
|
||||
})
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ class _Artist:
|
|||
|
||||
class _Album:
|
||||
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.name = name
|
||||
self.artists = artists or []
|
||||
|
|
@ -28,6 +30,12 @@ class _Album:
|
|||
self.total_tracks = total_tracks
|
||||
self.album_type = album_type
|
||||
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:
|
||||
|
|
@ -99,6 +107,27 @@ def test_search_kind_albums_handles_no_artists():
|
|||
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():
|
||||
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
|
||||
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
|
||||
- [stats-migration-plan.md](./stats-migration-plan.md)
|
||||
- route-specific migration plan for `stats`
|
||||
- [import-migration-plan.md](./import-migration-plan.md)
|
||||
- route-specific migration plan for `import`
|
||||
- implementation status and follow-up cleanup notes
|
||||
|
||||
## Naming Guidance
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
Snapshot date: 2026-05-14
|
||||
Snapshot date: 2026-05-15
|
||||
|
||||
## Summary
|
||||
- The shell route manifest now has 18 page ids.
|
||||
- `issues` and `stats` are now React-owned routes.
|
||||
- `issues`, `stats`, and `import` are now React-owned routes.
|
||||
- Since the last snapshot, the biggest changes are:
|
||||
- `downloads` was renamed into `search`.
|
||||
- The live queue became `active-downloads`.
|
||||
|
|
@ -78,9 +78,9 @@ Rollups:
|
|||
| --- | --- | --- | --- | --- | --- |
|
||||
| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed |
|
||||
| `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed |
|
||||
| `import` | React | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Completed |
|
||||
| `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 |
|
||||
| `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 |
|
||||
| `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 |
|
||||
| `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 |
|
||||
| `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
||||
| `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
||||
|
|
@ -130,11 +130,13 @@ Rollups:
|
|||
- Recommendation: low-risk route with a narrow surface.
|
||||
|
||||
#### `import`
|
||||
- Current owner: Legacy.
|
||||
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
|
||||
- Current owner: React.
|
||||
- Primary files: `webui/src/routes/import/*`, `webui/src/platform/shell/route-manifest.ts`.
|
||||
- Main surface: staging files, album and singles matching, suggestion cards, processing queue.
|
||||
- Key coupling: settings-derived staging path assumptions and downstream library state.
|
||||
- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `hydrabase`.
|
||||
- Recommendation: completed as the next migration after `stats`. 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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
## Final Recommendation
|
||||
- Keep `issues` and `stats` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior.
|
||||
- Keep `issues`, `stats`, and `import` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior.
|
||||
- Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history.
|
||||
- Migrate the remaining safe legacy routes first: `help`, `hydrabase`, and `import`.
|
||||
- Migrate the remaining safe legacy routes first: `help` and `hydrabase`.
|
||||
- `import` has already been migrated and should be treated as the first React-owned workflow route.
|
||||
- During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real.
|
||||
- Use `search` as the next meaningful proving ground now that the download queue has been split out.
|
||||
- Use `search` as the next larger proving ground after `import`, now that the download queue has been split out.
|
||||
- Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk.
|
||||
|
|
|
|||
153
webui/index.html
153
webui/index.html
|
|
@ -6199,159 +6199,6 @@
|
|||
</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 -->
|
||||
<div class="page" id="help-page">
|
||||
<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-dom": "^19.2.5",
|
||||
"recharts": "^3.8.1",
|
||||
"zod": "^4.4.2"
|
||||
"zod": "^4.4.2",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
|
|
@ -5391,6 +5392,35 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.13",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
||||
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@
|
|||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"recharts": "^3.8.1",
|
||||
"zod": "^4.4.2"
|
||||
"zod": "^4.4.2",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
.select {
|
||||
width: auto;
|
||||
min-width: 130px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
|
|
@ -95,6 +96,7 @@
|
|||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
min-height: 36px;
|
||||
padding: 8px 32px 8px 12px;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
|
|
@ -105,28 +107,204 @@
|
|||
-webkit-appearance: none;
|
||||
color-scheme: dark;
|
||||
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 {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
color-scheme: dark;
|
||||
&[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:
|
||||
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);
|
||||
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 {
|
||||
outline: none;
|
||||
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||
.switchThumb {
|
||||
display: block;
|
||||
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:
|
||||
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);
|
||||
radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%),
|
||||
rgb(var(--accent-light-rgb));
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.select option,
|
||||
.select optgroup {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
.rangeThumb:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.rangeThumb:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(var(--accent-light-rgb), 0.14),
|
||||
0 2px 8px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.optionCardGroup {
|
||||
|
|
@ -154,25 +332,24 @@
|
|||
border-color 0.18s ease,
|
||||
box-shadow 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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
&[data-selected='true'] {
|
||||
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 {
|
||||
|
|
@ -206,9 +383,22 @@
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
&[data-size='sm'] {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&[data-size='sm'] .optionButton {
|
||||
min-width: 0;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.optionButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
|
@ -218,24 +408,45 @@
|
|||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
padding: 10px 14px;
|
||||
padding: 8px 16px;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
box-shadow 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 {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
&[data-variant='ghost'] {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.optionButtonSelected {
|
||||
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-variant='ghost']:hover:not(:disabled) {
|
||||
transform: none;
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
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 {
|
||||
|
|
@ -260,24 +471,118 @@
|
|||
box-shadow 0.18s ease,
|
||||
background 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) {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: none;
|
||||
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
&[data-size='sm'] {
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormActions,
|
||||
FormError,
|
||||
FormField,
|
||||
|
|
@ -11,7 +12,9 @@ import {
|
|||
OptionButtonGroup,
|
||||
OptionCard,
|
||||
OptionCardGroup,
|
||||
RangeInput,
|
||||
Select,
|
||||
Switch,
|
||||
TextArea,
|
||||
TextInput,
|
||||
} from './form';
|
||||
|
|
@ -22,6 +25,9 @@ function FormDemo() {
|
|||
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
||||
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
||||
const [status, setStatus] = useState('open');
|
||||
const [archive, setArchive] = useState(false);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [confidence, setConfidence] = useState(90);
|
||||
|
||||
return (
|
||||
<form>
|
||||
|
|
@ -90,11 +96,31 @@ function FormDemo() {
|
|||
</Select>
|
||||
</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" />
|
||||
|
||||
<FormActions>
|
||||
<Button type="button">Cancel</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
Save
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
|
|
@ -109,9 +135,25 @@ describe('form primitives', () => {
|
|||
expect(screen.getByText('Short summary')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
|
||||
expect(screen.getByLabelText('Status')).toHaveValue('open');
|
||||
expect(screen.getByLabelText('Status')).toHaveAttribute('data-size', 'md');
|
||||
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
|
||||
expect(screen.getByLabelText('Status')).toHaveValue('resolved');
|
||||
|
||||
const archiveCheckbox = screen.getByRole('checkbox', { name: 'Archive' });
|
||||
expect(archiveCheckbox).not.toBeChecked();
|
||||
fireEvent.click(archiveCheckbox);
|
||||
expect(archiveCheckbox).toBeChecked();
|
||||
|
||||
const 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 wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
||||
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
|
||||
|
|
@ -126,6 +168,32 @@ describe('form primitives', () => {
|
|||
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('data-variant', 'primary');
|
||||
});
|
||||
|
||||
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 { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
|
||||
import { Field } from '@base-ui/react/field';
|
||||
import { Input as BaseInput } from '@base-ui/react/input';
|
||||
import { Slider } from '@base-ui/react/slider';
|
||||
import { Switch as BaseSwitch } from '@base-ui/react/switch';
|
||||
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
forwardRef,
|
||||
type CSSProperties,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ButtonHTMLAttributes,
|
||||
type SelectHTMLAttributes,
|
||||
|
|
@ -73,13 +77,118 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
|
|||
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(
|
||||
{ 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 },
|
||||
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({
|
||||
|
|
@ -112,7 +221,8 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
|
|||
<BaseToggle
|
||||
ref={ref}
|
||||
pressed={selected}
|
||||
className={clsx(styles.optionCard, selected && styles.optionCardSelected, className)}
|
||||
className={clsx(styles.optionCard, className)}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -129,31 +239,42 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
|
|||
);
|
||||
});
|
||||
|
||||
export function OptionButtonGroup({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
export type OptionButtonGroupSize = 'sm' | 'md';
|
||||
|
||||
export interface OptionButtonGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
|
||||
size?: OptionButtonGroupSize;
|
||||
}
|
||||
|
||||
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'> {
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
variant?: OptionButtonVariant;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
return (
|
||||
<BaseToggle
|
||||
ref={ref}
|
||||
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}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -166,13 +287,24 @@ type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;
|
|||
|
||||
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'icon';
|
||||
variant?: 'default' | 'primary' | 'secondary' | 'ghost';
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, type = 'button', ...props },
|
||||
{ className, size = 'md', type = 'button', variant = 'default', ...props },
|
||||
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 }) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
interface Window {
|
||||
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;
|
||||
SoulSyncWorkflowActions?: {
|
||||
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ describe('shellRouteManifest', () => {
|
|||
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
|
||||
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
@ -31,6 +33,13 @@ describe('shellRouteManifest', () => {
|
|||
expect(resolveShellPageFromPath('/issues/')).toBe('issues');
|
||||
});
|
||||
|
||||
it('resolves nested React route paths to their shell page', () => {
|
||||
expect(resolveShellPageFromPath('/import/album')).toBe('import');
|
||||
expect(resolveShellPageFromPath('/import/auto')).toBe('import');
|
||||
expect(resolveShellPageFromPath('/import/singles')).toBe('import');
|
||||
expect(resolveLegacyShellPageFromPath('/import/album')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a route entry for every manifest page id', () => {
|
||||
expect(shellRouteManifest).not.toHaveLength(0);
|
||||
expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard');
|
||||
|
|
@ -43,8 +52,9 @@ describe('shellRouteManifest', () => {
|
|||
it('tracks whether a route is rendered by React or the legacy shell', () => {
|
||||
expect(getShellRouteByPageId('issues')?.kind).toBe('react');
|
||||
expect(getShellRouteByPageId('stats')?.kind).toBe('react');
|
||||
expect(getShellRouteByPageId('import')?.kind).toBe('react');
|
||||
expect(getShellRouteByPageId('discover')?.kind).toBe('legacy');
|
||||
expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['stats', 'issues']);
|
||||
expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['import', 'stats', 'issues']);
|
||||
expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [
|
|||
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
|
||||
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
|
||||
{ pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' },
|
||||
{ pageId: 'import', path: '/import', kind: 'legacy' },
|
||||
{ pageId: 'import', path: '/import', kind: 'react' },
|
||||
{ pageId: 'library', path: '/library', kind: 'legacy' },
|
||||
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
|
||||
{ pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' },
|
||||
|
|
@ -67,7 +67,11 @@ export function getShellRouteByPageId(pageId: ShellPageId): ShellRouteDefinition
|
|||
}
|
||||
|
||||
export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined {
|
||||
return routeByPath.get(normalizeShellPath(pathname) as `/${string}`);
|
||||
const normalized = normalizeShellPath(pathname);
|
||||
const exactRoute = routeByPath.get(normalized as `/${string}`);
|
||||
if (exactRoute) return exactRoute;
|
||||
|
||||
return reactShellRoutes.find((route) => normalized.startsWith(`${route.path}/`));
|
||||
}
|
||||
|
||||
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||
import { Route as SplatRouteImport } from './routes/$'
|
||||
import { Route as StatsRouteRouteImport } from './routes/stats/route'
|
||||
import { Route as IssuesRouteRouteImport } from './routes/issues/route'
|
||||
import { Route as ImportRouteRouteImport } from './routes/import/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ImportIndexRouteImport } from './routes/import/index'
|
||||
import { Route as ImportSinglesRouteImport } from './routes/import/singles'
|
||||
import { Route as ImportAutoRouteImport } from './routes/import/auto'
|
||||
import { Route as ImportAlbumRouteImport } from './routes/import/album'
|
||||
import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id'
|
||||
|
||||
const SplatRoute = SplatRouteImport.update({
|
||||
|
|
@ -30,11 +35,36 @@ const IssuesRouteRoute = IssuesRouteRouteImport.update({
|
|||
path: '/issues',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ImportRouteRoute = ImportRouteRouteImport.update({
|
||||
id: '/import',
|
||||
path: '/import',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ImportIndexRoute = ImportIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => ImportRouteRoute,
|
||||
} as any)
|
||||
const ImportSinglesRoute = ImportSinglesRouteImport.update({
|
||||
id: '/singles',
|
||||
path: '/singles',
|
||||
getParentRoute: () => ImportRouteRoute,
|
||||
} as any)
|
||||
const ImportAutoRoute = ImportAutoRouteImport.update({
|
||||
id: '/auto',
|
||||
path: '/auto',
|
||||
getParentRoute: () => ImportRouteRoute,
|
||||
} as any)
|
||||
const ImportAlbumRoute = ImportAlbumRouteImport.update({
|
||||
id: '/album',
|
||||
path: '/album',
|
||||
getParentRoute: () => ImportRouteRoute,
|
||||
} as any)
|
||||
const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
|
||||
id: '/artist-detail/$source/$id',
|
||||
path: '/artist-detail/$source/$id',
|
||||
|
|
@ -43,9 +73,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
|
|||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/import': typeof ImportRouteRouteWithChildren
|
||||
'/issues': typeof IssuesRouteRoute
|
||||
'/stats': typeof StatsRouteRoute
|
||||
'/$': typeof SplatRoute
|
||||
'/import/album': typeof ImportAlbumRoute
|
||||
'/import/auto': typeof ImportAutoRoute
|
||||
'/import/singles': typeof ImportSinglesRoute
|
||||
'/import/': typeof ImportIndexRoute
|
||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
|
|
@ -53,32 +88,66 @@ export interface FileRoutesByTo {
|
|||
'/issues': typeof IssuesRouteRoute
|
||||
'/stats': typeof StatsRouteRoute
|
||||
'/$': typeof SplatRoute
|
||||
'/import/album': typeof ImportAlbumRoute
|
||||
'/import/auto': typeof ImportAutoRoute
|
||||
'/import/singles': typeof ImportSinglesRoute
|
||||
'/import': typeof ImportIndexRoute
|
||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/import': typeof ImportRouteRouteWithChildren
|
||||
'/issues': typeof IssuesRouteRoute
|
||||
'/stats': typeof StatsRouteRoute
|
||||
'/$': typeof SplatRoute
|
||||
'/import/album': typeof ImportAlbumRoute
|
||||
'/import/auto': typeof ImportAutoRoute
|
||||
'/import/singles': typeof ImportSinglesRoute
|
||||
'/import/': typeof ImportIndexRoute
|
||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/import'
|
||||
| '/issues'
|
||||
| '/stats'
|
||||
| '/$'
|
||||
| '/import/album'
|
||||
| '/import/auto'
|
||||
| '/import/singles'
|
||||
| '/import/'
|
||||
| '/artist-detail/$source/$id'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
|
||||
id:
|
||||
| '__root__'
|
||||
to:
|
||||
| '/'
|
||||
| '/issues'
|
||||
| '/stats'
|
||||
| '/$'
|
||||
| '/import/album'
|
||||
| '/import/auto'
|
||||
| '/import/singles'
|
||||
| '/import'
|
||||
| '/artist-detail/$source/$id'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/import'
|
||||
| '/issues'
|
||||
| '/stats'
|
||||
| '/$'
|
||||
| '/import/album'
|
||||
| '/import/auto'
|
||||
| '/import/singles'
|
||||
| '/import/'
|
||||
| '/artist-detail/$source/$id'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ImportRouteRoute: typeof ImportRouteRouteWithChildren
|
||||
IssuesRouteRoute: typeof IssuesRouteRoute
|
||||
StatsRouteRoute: typeof StatsRouteRoute
|
||||
SplatRoute: typeof SplatRoute
|
||||
|
|
@ -108,6 +177,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IssuesRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/import': {
|
||||
id: '/import'
|
||||
path: '/import'
|
||||
fullPath: '/import'
|
||||
preLoaderRoute: typeof ImportRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
|
|
@ -115,6 +191,34 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/import/': {
|
||||
id: '/import/'
|
||||
path: '/'
|
||||
fullPath: '/import/'
|
||||
preLoaderRoute: typeof ImportIndexRouteImport
|
||||
parentRoute: typeof ImportRouteRoute
|
||||
}
|
||||
'/import/singles': {
|
||||
id: '/import/singles'
|
||||
path: '/singles'
|
||||
fullPath: '/import/singles'
|
||||
preLoaderRoute: typeof ImportSinglesRouteImport
|
||||
parentRoute: typeof ImportRouteRoute
|
||||
}
|
||||
'/import/auto': {
|
||||
id: '/import/auto'
|
||||
path: '/auto'
|
||||
fullPath: '/import/auto'
|
||||
preLoaderRoute: typeof ImportAutoRouteImport
|
||||
parentRoute: typeof ImportRouteRoute
|
||||
}
|
||||
'/import/album': {
|
||||
id: '/import/album'
|
||||
path: '/album'
|
||||
fullPath: '/import/album'
|
||||
preLoaderRoute: typeof ImportAlbumRouteImport
|
||||
parentRoute: typeof ImportRouteRoute
|
||||
}
|
||||
'/artist-detail/$source/$id': {
|
||||
id: '/artist-detail/$source/$id'
|
||||
path: '/artist-detail/$source/$id'
|
||||
|
|
@ -125,8 +229,27 @@ declare module '@tanstack/react-router' {
|
|||
}
|
||||
}
|
||||
|
||||
interface ImportRouteRouteChildren {
|
||||
ImportAlbumRoute: typeof ImportAlbumRoute
|
||||
ImportAutoRoute: typeof ImportAutoRoute
|
||||
ImportSinglesRoute: typeof ImportSinglesRoute
|
||||
ImportIndexRoute: typeof ImportIndexRoute
|
||||
}
|
||||
|
||||
const ImportRouteRouteChildren: ImportRouteRouteChildren = {
|
||||
ImportAlbumRoute: ImportAlbumRoute,
|
||||
ImportAutoRoute: ImportAutoRoute,
|
||||
ImportSinglesRoute: ImportSinglesRoute,
|
||||
ImportIndexRoute: ImportIndexRoute,
|
||||
}
|
||||
|
||||
const ImportRouteRouteWithChildren = ImportRouteRoute._addFileChildren(
|
||||
ImportRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ImportRouteRoute: ImportRouteRouteWithChildren,
|
||||
IssuesRouteRoute: IssuesRouteRoute,
|
||||
StatsRouteRoute: StatsRouteRoute,
|
||||
SplatRoute: SplatRoute,
|
||||
|
|
|
|||
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();
|
||||
loadBlacklistCount();
|
||||
break;
|
||||
case 'import':
|
||||
initializeImportPage();
|
||||
break;
|
||||
case 'hydrabase':
|
||||
// Check connection status and pre-fill saved credentials
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1834,7 +1834,7 @@
|
|||
/* --- Phase 9: Touch & Hover Adaptations --- */
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
|
|
@ -2312,86 +2312,9 @@
|
|||
opacity: 0.7;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
/* Import Page - Touch fallback */
|
||||
.import-page-file-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.import-page-match-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Import Page — small screen */
|
||||
@media (max-width: 768px) {
|
||||
.import-page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.import-page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.import-page-album-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-page-album-hero {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.import-page-album-hero img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.import-page-match-row {
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.import-page-match-file {
|
||||
grid-column: 1 / -1;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.import-page-match-unmatch {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.import-page-singles-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.import-page-singles-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.import-page-single-item {
|
||||
grid-template-columns: 28px 1fr;
|
||||
}
|
||||
|
||||
.import-page-single-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.import-page-single-search-panel {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.import-page-staging-bar {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Profile Picker - Mobile */
|
||||
.profile-picker-grid {
|
||||
gap: 20px;
|
||||
|
|
|
|||
|
|
@ -1190,9 +1190,7 @@ async function loadInitialData() {
|
|||
if (route?.kind === 'react') {
|
||||
showReactHost(targetPage);
|
||||
setActivePageChrome(targetPage);
|
||||
if (window.location.pathname !== route.path) {
|
||||
history.replaceState({ page: targetPage }, '', route.path);
|
||||
}
|
||||
// Keep nested react-tab URLs like /import/auto or /import/singles intact.
|
||||
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)?$/;
|
||||
}
|
||||
|
||||
if (pageId === 'import') {
|
||||
return /\/import\/album$/;
|
||||
}
|
||||
|
||||
return new RegExp(`${path.replace('/', '\\/')}$`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue