feat(webui): clarify import source metadata

- thread primary_source through album and track search payloads while keeping per-result source on the returned rows
- reuse the shared Notice primitive for fallback and error messaging in the import pages
- update the import route tests and shell route smoke coverage for the new behavior
This commit is contained in:
Antti Kettunen 2026-05-24 15:40:13 +03:00
parent 2a7c03f398
commit 9da8251e43
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
9 changed files with 211 additions and 38 deletions

View file

@ -18,7 +18,9 @@ describe('shellRouteManifest', () => {
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist'); expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads'); expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveShellPageFromPath('/artist-detail')).toBeNull(); expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe('artist-detail'); expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe(
'artist-detail',
);
expect(resolveShellPageFromPath('/artists')).toBeNull(); expect(resolveShellPageFromPath('/artists')).toBeNull();
}); });

View file

@ -11,6 +11,19 @@ import type {
export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png'; 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 { export function getStagingFileKey(file: ImportStagingFile): string {
return file.full_path; return file.full_path;
} }
@ -27,6 +40,46 @@ export function getStagingStatsText(files: ImportStagingFile[]): string {
return totalSize ? `${fileLabel} - ${formatImportBytes(totalSize)}` : fileLabel; 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) { export function getTrackDisplayInfo(match: ImportAlbumMatch, index: number) {
const track = match.track || match.spotify_track || {}; const track = match.track || match.spotify_track || {};
const rawTrackNumber = track.track_number ?? track.trackNumber ?? null; const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;

View file

@ -10,6 +10,7 @@ import type {
ImportStagingFile, ImportStagingFile,
ImportTrackResult, ImportTrackResult,
} from './-import.types'; } from './-import.types';
import { getStagingFileKey } from './-import.helpers'; import { getStagingFileKey } from './-import.helpers';
export type SingleSearchState = { export type SingleSearchState = {
@ -33,6 +34,8 @@ function createInitialWorkflowState() {
albumResults: null as ImportAlbumResult[] | null, albumResults: null as ImportAlbumResult[] | null,
albumSearchError: null as string | null, albumSearchError: null as string | null,
albumSearchLoading: false, 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, autoGroupFilePaths: null as string[] | null,
selectedAlbum: null as ImportAlbumResult | null, selectedAlbum: null as ImportAlbumResult | null,
albumMatch: null as ImportAlbumMatchPayload | null, albumMatch: null as ImportAlbumMatchPayload | null,
@ -82,6 +85,7 @@ export const useImportWorkflowStore = create(
albumResults: null, albumResults: null,
albumSearchError: null, albumSearchError: null,
albumSearchLoading: false, albumSearchLoading: false,
albumSearchLookupSource: null,
autoGroupFilePaths: null, autoGroupFilePaths: null,
selectedAlbum: null, selectedAlbum: null,
albumMatch: null, albumMatch: null,
@ -94,12 +98,15 @@ export const useImportWorkflowStore = create(
setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }), setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }),
setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }), setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }),
setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }), setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }),
setAlbumSearchLookupSource: (albumSearchLookupSource: string | null) =>
set({ albumSearchLookupSource }),
setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => { setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => {
set({ set({
albumQuery, albumQuery,
albumSearchLoading: true, albumSearchLoading: true,
albumSearchError: null, albumSearchError: null,
albumResults: null, albumResults: null,
albumSearchLookupSource: null,
selectedAlbum: null, selectedAlbum: null,
albumMatch: null, albumMatch: null,
autoGroupFilePaths, autoGroupFilePaths,
@ -219,6 +226,7 @@ export function useAlbumImportWorkflow() {
albumResults: state.albumResults, albumResults: state.albumResults,
albumSearchError: state.albumSearchError, albumSearchError: state.albumSearchError,
albumSearchLoading: state.albumSearchLoading, albumSearchLoading: state.albumSearchLoading,
albumSearchLookupSource: state.albumSearchLookupSource,
autoGroupFilePaths: state.autoGroupFilePaths, autoGroupFilePaths: state.autoGroupFilePaths,
clearAutoGroupFilePaths: state.clearAutoGroupFilePaths, clearAutoGroupFilePaths: state.clearAutoGroupFilePaths,
matchOverrides: state.matchOverrides, matchOverrides: state.matchOverrides,
@ -232,6 +240,7 @@ export function useAlbumImportWorkflow() {
setAlbumSearchContext: state.setAlbumSearchContext, setAlbumSearchContext: state.setAlbumSearchContext,
setAlbumSearchError: state.setAlbumSearchError, setAlbumSearchError: state.setAlbumSearchError,
setAlbumSearchLoading: state.setAlbumSearchLoading, setAlbumSearchLoading: state.setAlbumSearchLoading,
setAlbumSearchLookupSource: state.setAlbumSearchLookupSource,
setMatchOverrides: state.setMatchOverrides, setMatchOverrides: state.setMatchOverrides,
setSelectedAlbum: state.setSelectedAlbum, setSelectedAlbum: state.setSelectedAlbum,
})), })),

View file

@ -53,7 +53,8 @@ export interface ImportAlbumResult {
id: string; id: string;
name: string; name: string;
artist: string; artist: string;
source?: string | null; /** Provider that returned this result row. */
source: string;
image_url?: string | null; image_url?: string | null;
total_tracks?: number | null; total_tracks?: number | null;
release_date?: string | null; release_date?: string | null;
@ -63,6 +64,8 @@ export interface ImportAlbumSearchPayload {
success: boolean; success: boolean;
albums?: ImportAlbumResult[]; albums?: ImportAlbumResult[];
suggestions?: ImportAlbumResult[]; suggestions?: ImportAlbumResult[];
/** Provider used to seed the lookup chain for this response. */
primary_source?: string | null;
ready?: boolean; ready?: boolean;
error?: string; error?: string;
} }
@ -72,7 +75,8 @@ export interface ImportTrackResult {
name: string; name: string;
artist: string; artist: string;
album?: string | null; album?: string | null;
source?: string | null; /** Provider that returned this result row. */
source: string;
image_url?: string | null; image_url?: string | null;
duration_ms?: number | null; duration_ms?: number | null;
} }
@ -80,6 +84,8 @@ export interface ImportTrackResult {
export interface ImportTrackSearchPayload { export interface ImportTrackSearchPayload {
success: boolean; success: boolean;
tracks?: ImportTrackResult[]; tracks?: ImportTrackResult[];
/** Provider used to seed the lookup chain for this response. */
primary_source?: string | null;
error?: string; error?: string;
} }
@ -87,7 +93,8 @@ export interface ImportAlbum {
id?: string | number | null; id?: string | number | null;
name: string; name: string;
artist: string; artist: string;
source?: string | null; /** Provider used to resolve this selected album. */
source: string;
image_url?: string | null; image_url?: string | null;
total_tracks?: number | null; total_tracks?: number | null;
release_date?: string | null; release_date?: string | null;

View file

@ -2,13 +2,13 @@ import { createMemoryHistory } from '@tanstack/react-router';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 { HttpResponse, http, server } from '@/test/msw';
import { createShellBridge } from '@/test/shell-bridge'; import { createShellBridge } from '@/test/shell-bridge';
import { createAppQueryClient } from '@/app/query-client';
import { AppRouterProvider, createAppRouter } from '@/app/router';
import type { ImportStagingFile } from './-import.types'; import type { ImportStagingFile } from './-import.types';
import { resetImportWorkflowStore } from './-import.store'; import { resetImportWorkflowStore } from './-import.store';
function renderImportRoute(initialEntries = ['/import']) { function renderImportRoute(initialEntries = ['/import']) {
@ -86,6 +86,7 @@ describe('import route', () => {
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
ready: true, ready: true,
primary_source: 'spotify',
suggestions: [ suggestions: [
{ {
id: 'album-1', id: 'album-1',
@ -101,6 +102,7 @@ describe('import route', () => {
http.get('/api/import/search/albums', () => { http.get('/api/import/search/albums', () => {
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
primary_source: 'spotify',
albums: [ albums: [
{ {
id: 'album-1', id: 'album-1',
@ -192,11 +194,17 @@ describe('import route', () => {
await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument()); await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument());
expect(await screen.findByText('Import Music')).toBeInTheDocument(); expect(await screen.findByText('Import Music')).toBeInTheDocument();
expect(screen.getByText('Import: /music/Staging')).toBeInTheDocument(); expect(screen.getByText('Import: /music/Staging')).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(history.location.pathname).toBe('/import/album'));
await waitFor(() => await waitFor(() =>
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(true), 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(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
true,
);
expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('import'); expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('import');
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import'); expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import');
}); });
@ -265,6 +273,40 @@ describe('import route', () => {
}); });
}); });
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 () => { it('renders auto-import results from route search state', async () => {
renderImportRoute(['/import/auto?autoFilter=pending']); renderImportRoute(['/import/auto?autoFilter=pending']);

View file

@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { type DragEvent, type KeyboardEvent, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { type DragEvent, type KeyboardEvent, useState } from 'react';
import { Button, TextInput } from '@/components/form/form'; import { Button, TextInput } from '@/components/form/form';
import { Notice } from '@/components/primitives';
import type { ImportAlbumResult } from '../-import.types'; import type { ImportAlbumResult } from '../-import.types';
@ -15,6 +16,8 @@ import {
import { import {
getDisplayedMatchFile, getDisplayedMatchFile,
getEffectiveAlbumMatches, getEffectiveAlbumMatches,
getImportSourceBadgeText,
getImportSourceFallbackBanner,
getTrackDisplayInfo, getTrackDisplayInfo,
getUnmatchedStagingFiles, getUnmatchedStagingFiles,
IMPORT_PLACEHOLDER_IMAGE, IMPORT_PLACEHOLDER_IMAGE,
@ -47,6 +50,7 @@ function useAlbumImportViewModel() {
albumResults, albumResults,
albumSearchError, albumSearchError,
albumSearchLoading, albumSearchLoading,
albumSearchLookupSource,
autoGroupFilePaths, autoGroupFilePaths,
clearAutoGroupFilePaths, clearAutoGroupFilePaths,
matchOverrides, matchOverrides,
@ -60,6 +64,7 @@ function useAlbumImportViewModel() {
setAlbumSearchContext, setAlbumSearchContext,
setAlbumSearchError, setAlbumSearchError,
setAlbumSearchLoading, setAlbumSearchLoading,
setAlbumSearchLookupSource,
setMatchOverrides, setMatchOverrides,
setSelectedAlbum, setSelectedAlbum,
} = useAlbumImportWorkflow(); } = useAlbumImportWorkflow();
@ -68,7 +73,7 @@ function useAlbumImportViewModel() {
setDragOverTrack(null); setDragOverTrack(null);
setTapSelectedChip(null); setTapSelectedChip(null);
resetAlbumWorkflow(); resetAlbumWorkflow();
refreshStaging(); void refreshStaging();
}; };
const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => { const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => {
@ -80,6 +85,7 @@ function useAlbumImportViewModel() {
try { try {
const payload = await searchImportAlbums(trimmed); const payload = await searchImportAlbums(trimmed);
setAlbumResults(payload.albums ?? []); setAlbumResults(payload.albums ?? []);
setAlbumSearchLookupSource(payload.primary_source ?? null);
} catch (error) { } catch (error) {
setAlbumSearchError(getErrorMessage(error)); setAlbumSearchError(getErrorMessage(error));
} finally { } finally {
@ -94,6 +100,8 @@ function useAlbumImportViewModel() {
setAlbumMatchLoading(true); setAlbumMatchLoading(true);
try { 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({ const payload = await matchImportAlbum({
albumId: album.id, albumId: album.id,
source: album.source, source: album.source,
@ -165,6 +173,7 @@ function useAlbumImportViewModel() {
albumResults, albumResults,
albumSearchError, albumSearchError,
albumSearchLoading, albumSearchLoading,
albumSearchLookupSource,
dragOverTrack, dragOverTrack,
groups: groupsQuery.data?.groups ?? [], groups: groupsQuery.data?.groups ?? [],
matchOverrides, matchOverrides,
@ -200,6 +209,7 @@ function useAlbumImportViewModel() {
stagingFiles, stagingFiles,
suggestions: suggestionsQuery.data?.suggestions ?? [], suggestions: suggestionsQuery.data?.suggestions ?? [],
suggestionsReady: suggestionsQuery.data?.ready ?? true, suggestionsReady: suggestionsQuery.data?.ready ?? true,
suggestionsLookupSource: suggestionsQuery.data?.primary_source ?? null,
tapSelectedChip, tapSelectedChip,
}; };
} }
@ -221,6 +231,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
albumResults, albumResults,
albumSearchError, albumSearchError,
albumSearchLoading, albumSearchLoading,
albumSearchLookupSource,
groups, groups,
onAlbumQueryChange, onAlbumQueryChange,
onBackToSearch, onBackToSearch,
@ -230,13 +241,25 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
selectedAlbum, selectedAlbum,
suggestions, suggestions,
suggestionsReady, suggestionsReady,
suggestionsLookupSource,
} = viewModel; } = viewModel;
const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch; const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch;
const suggestionsFallbackBanner = getImportSourceFallbackBanner(
suggestions,
suggestionsLookupSource,
);
const albumResultsFallbackBanner = getImportSourceFallbackBanner(
albumResults,
albumSearchLookupSource,
);
return ( return (
<> <>
<div id="import-page-album-search-section" className={clsx({ [styles.hidden]: showingMatch })}> <div
id="import-page-album-search-section"
className={clsx({ [styles.hidden]: showingMatch })}
>
{albumResults === null && ( {albumResults === null && (
<> <>
{groups.length > 0 && ( {groups.length > 0 && (
@ -266,12 +289,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
<div className={styles.importPageSuggestions} id="import-page-suggestions"> <div className={styles.importPageSuggestions} id="import-page-suggestions">
<div className={styles.importPageSectionLabel}>Suggested from your import folder</div> <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"> <div className={styles.importPageAlbumGrid} id="import-page-suggestions-grid">
{suggestions.length > 0 ? ( {suggestions.length > 0 ? (
suggestions.map((album) => ( suggestions.map((album) => (
<AlbumCard <AlbumCard
key={`${album.source || 'source'}-${album.id}`} key={`${album.source || 'source'}-${album.id}`}
album={album} album={album}
lookupSource={suggestionsLookupSource}
onSelect={onSelectAlbum} onSelect={onSelectAlbum}
/> />
)) ))
@ -310,11 +337,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
</Button> </Button>
</div> </div>
{albumResultsFallbackBanner ? (
<Notice tone="warning">{albumResultsFallbackBanner}</Notice>
) : null}
<div className={styles.importPageAlbumGrid} id="import-page-album-results"> <div className={styles.importPageAlbumGrid} id="import-page-album-results">
{albumSearchLoading ? ( {albumSearchLoading ? (
<div className={styles.importPageEmptyState}>Searching...</div> <div className={styles.importPageEmptyState}>Searching...</div>
) : albumSearchError ? ( ) : albumSearchError ? (
<div className={styles.importPageEmptyState}>Error: {albumSearchError}</div> <Notice tone="danger" role="alert">
Error: {albumSearchError}
</Notice>
) : albumResults?.length === 0 ? ( ) : albumResults?.length === 0 ? (
<div className={styles.importPageEmptyState}>No albums found</div> <div className={styles.importPageEmptyState}>No albums found</div>
) : ( ) : (
@ -322,6 +354,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
<AlbumCard <AlbumCard
key={`${album.source || 'source'}-${album.id}`} key={`${album.source || 'source'}-${album.id}`}
album={album} album={album}
lookupSource={albumSearchLookupSource}
onSelect={onSelectAlbum} onSelect={onSelectAlbum}
/> />
)) ))
@ -329,11 +362,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
</div> </div>
</div> </div>
<div id="import-page-album-match-section" className={clsx({ [styles.hidden]: !showingMatch })}> <div
id="import-page-album-match-section"
className={clsx({ [styles.hidden]: !showingMatch })}
>
{albumMatchLoading ? ( {albumMatchLoading ? (
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div> <div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
) : albumMatchError ? ( ) : albumMatchError ? (
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div> <Notice tone="danger" role="alert">
Error: {albumMatchError}
</Notice>
) : albumMatch?.album ? ( ) : albumMatch?.album ? (
<AlbumMatchPanel viewModel={viewModel} /> <AlbumMatchPanel viewModel={viewModel} />
) : ( ) : (
@ -348,13 +386,17 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
function AlbumCard({ function AlbumCard({
album, album,
lookupSource,
onSelect, onSelect,
}: { }: {
album: ImportAlbumResult; album: ImportAlbumResult;
lookupSource: string | null;
onSelect: (album: ImportAlbumResult) => void; onSelect: (album: ImportAlbumResult) => void;
}) { }) {
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
return ( return (
<button className={styles.importPageAlbumCard} onClick={() => onSelect(album)}> <button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
<img <img
src={album.image_url || IMPORT_PLACEHOLDER_IMAGE} src={album.image_url || IMPORT_PLACEHOLDER_IMAGE}
alt={album.name} alt={album.name}
@ -370,6 +412,9 @@ function AlbumCard({
<div className={styles.importPageAlbumCardMeta}> <div className={styles.importPageAlbumCardMeta}>
{album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''} {album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
</div> </div>
{resultSourceBadge ? (
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
) : null}
</button> </button>
); );
} }
@ -407,7 +452,9 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
return albumMatchLoading ? ( return albumMatchLoading ? (
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div> <div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
) : albumMatchError ? ( ) : albumMatchError ? (
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div> <Notice tone="danger" role="alert">
Error: {albumMatchError}
</Notice>
) : albumMatch?.album ? ( ) : albumMatch?.album ? (
<> <>
<div className={styles.importPageAlbumHero} id="import-page-album-hero"> <div className={styles.importPageAlbumHero} id="import-page-album-hero">
@ -474,9 +521,11 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
> >
<span className={styles.importPageMatchNum}>{trackInfo.displayTrackNumber}</span> <span className={styles.importPageMatchNum}>{trackInfo.displayTrackNumber}</span>
<span className={styles.importPageMatchTrack}>{trackInfo.name}</span> <span className={styles.importPageMatchTrack}>{trackInfo.name}</span>
<span className={clsx(styles.importPageMatchFile, { <span
[styles.hasFile]: file, className={clsx(styles.importPageMatchFile, {
})}> [styles.hasFile]: file,
})}
>
{file ? ( {file ? (
<> <>
<span className={styles.importPageMatchFileName}>{file.filename}</span> <span className={styles.importPageMatchFileName}>{file.filename}</span>

View file

@ -237,6 +237,13 @@
margin-top: 4px; margin-top: 4px;
} }
.importPageAlbumCardSource {
font-size: 10px;
color: rgba(255, 200, 100, 0.75);
margin-top: 2px;
font-style: italic;
}
/* Album hero (selected album) */ /* Album hero (selected album) */
.importPageAlbumHero { .importPageAlbumHero {
display: flex; display: flex;
@ -639,11 +646,11 @@
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
} }
.importPageSingleResultSelect { .importPageSingleResultSelect {
padding: 4px 12px; padding: 4px 12px;
background: rgba(var(--accent-light-rgb), 0.15); background: rgba(var(--accent-light-rgb), 0.15);
border: 1px solid rgba(var(--accent-light-rgb), 0.3); border: 1px solid rgba(var(--accent-light-rgb), 0.3);
border-radius: 6px; border-radius: 6px;
color: rgb(var(--accent-light-rgb)); color: rgb(var(--accent-light-rgb));
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@ -651,14 +658,14 @@
flex-shrink: 0; flex-shrink: 0;
} }
.importPageSingleResultSelect:hover { .importPageSingleResultSelect:hover {
background: rgba(var(--accent-light-rgb), 0.25); background: rgba(var(--accent-light-rgb), 0.25);
} }
/* Empty state */ /* Empty state */
.importPageEmptyState { .importPageEmptyState {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.3);
font-size: 14px; font-size: 14px;
} }
@ -821,8 +828,8 @@
.importPageMatchRow { .importPageMatchRow {
grid-template-columns: 28px minmax(0, 1fr) auto; grid-template-columns: 28px minmax(0, 1fr) auto;
grid-template-areas: grid-template-areas:
"num track track" 'num track track'
"num file unmatch"; 'num file unmatch';
gap: 6px 8px; gap: 6px 8px;
align-items: center; align-items: center;
} }
@ -853,8 +860,8 @@
.importPageSingleItem { .importPageSingleItem {
grid-template-columns: 28px minmax(0, 1fr) auto; grid-template-columns: 28px minmax(0, 1fr) auto;
grid-template-areas: grid-template-areas:
"checkbox info actions" 'checkbox info actions'
"search search search"; 'search search search';
align-items: start; align-items: start;
gap: 8px; gap: 8px;
} }

View file

@ -140,7 +140,8 @@ function ImportProcessingQueue() {
function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) { function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) {
const statusText = getQueueStatusText(entry); const statusText = getQueueStatusText(entry);
const statusClass = clsx({ const statusClass = clsx({
[styles.error]: entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0), [styles.error]:
entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0),
[styles.done]: entry.status === 'done', [styles.done]: entry.status === 'done',
}); });

View file

@ -2,6 +2,7 @@ import clsx from 'clsx';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Badge, Button, Checkbox, TextInput } from '@/components/form/form'; import { Badge, Button, Checkbox, TextInput } from '@/components/form/form';
import { Notice } from '@/components/primitives';
import type { SingleSearchState } from '../-import.store'; import type { SingleSearchState } from '../-import.store';
import type { ImportTrackResult } from '../-import.types'; import type { ImportTrackResult } from '../-import.types';
@ -113,7 +114,7 @@ export function SinglesImportTab() {
}); });
clearSinglesSelection(); clearSinglesSelection();
refreshStaging(); void refreshStaging();
}; };
return ( return (
@ -295,7 +296,9 @@ function SingleSearchPanel({
{searchState?.loading ? ( {searchState?.loading ? (
<div className={styles.importPageEmptyState}>Searching...</div> <div className={styles.importPageEmptyState}>Searching...</div>
) : searchState?.error ? ( ) : searchState?.error ? (
<div className={styles.importPageEmptyState}>Error: {searchState.error}</div> <Notice tone="danger" role="alert">
Error: {searchState.error}
</Notice>
) : searchState?.results.length === 0 ? ( ) : searchState?.results.length === 0 ? (
<div className={styles.importPageEmptyState}>No results found</div> <div className={styles.importPageEmptyState}>No results found</div>
) : ( ) : (