Merge pull request #686 from kettui/feat/react-migration-import

feat(webui): migrate import page to React
This commit is contained in:
BoulderBadgeDad 2026-05-24 14:16:05 -07:00 committed by GitHub
commit a1222d5a8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 6767 additions and 3389 deletions

View file

@ -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:

View file

@ -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',

View file

@ -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."
)

View file

@ -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

View 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

View file

@ -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.

View file

@ -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">

View file

@ -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
}
}
}
}
}

View file

@ -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",

View file

@ -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 {

View file

@ -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');
});
});

View file

@ -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 }) {

View file

@ -1 +1 @@
export { Show } from './show';
export * from './primitives';

View 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);
}

View 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');
});
});

View 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}
/>
);
});

View file

@ -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();
});
});

View file

@ -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}</>;
}

View file

@ -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>;

View file

@ -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);
});

View file

@ -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 {

View file

@ -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,

View 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);
});
});

View 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'] }),
]);
}

View 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}`;
}

View 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,
})),
);
}

View 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;

View 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();
});
});

View 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);
}

View 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);
}

File diff suppressed because it is too large Load diff

View 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>
);
}

View 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';
}

View 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>
);
}

View 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,
});

View 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} />;
}

View 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 });
},
});

View 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,
});

View 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,
});

View file

@ -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 {

View file

@ -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;

View file

@ -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

View file

@ -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('/', '\\/')}$`);
}