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:
parent
2a7c03f398
commit
9da8251e43
9 changed files with 211 additions and 38 deletions
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,19 @@ import type {
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
@ -27,6 +40,46 @@ export function getStagingStatsText(files: ImportStagingFile[]): string {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ImportStagingFile,
|
||||
ImportTrackResult,
|
||||
} from './-import.types';
|
||||
|
||||
import { getStagingFileKey } from './-import.helpers';
|
||||
|
||||
export type SingleSearchState = {
|
||||
|
|
@ -33,6 +34,8 @@ function createInitialWorkflowState() {
|
|||
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,
|
||||
|
|
@ -82,6 +85,7 @@ export const useImportWorkflowStore = create(
|
|||
albumResults: null,
|
||||
albumSearchError: null,
|
||||
albumSearchLoading: false,
|
||||
albumSearchLookupSource: null,
|
||||
autoGroupFilePaths: null,
|
||||
selectedAlbum: null,
|
||||
albumMatch: null,
|
||||
|
|
@ -94,12 +98,15 @@ export const useImportWorkflowStore = create(
|
|||
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,
|
||||
|
|
@ -219,6 +226,7 @@ export function useAlbumImportWorkflow() {
|
|||
albumResults: state.albumResults,
|
||||
albumSearchError: state.albumSearchError,
|
||||
albumSearchLoading: state.albumSearchLoading,
|
||||
albumSearchLookupSource: state.albumSearchLookupSource,
|
||||
autoGroupFilePaths: state.autoGroupFilePaths,
|
||||
clearAutoGroupFilePaths: state.clearAutoGroupFilePaths,
|
||||
matchOverrides: state.matchOverrides,
|
||||
|
|
@ -232,6 +240,7 @@ export function useAlbumImportWorkflow() {
|
|||
setAlbumSearchContext: state.setAlbumSearchContext,
|
||||
setAlbumSearchError: state.setAlbumSearchError,
|
||||
setAlbumSearchLoading: state.setAlbumSearchLoading,
|
||||
setAlbumSearchLookupSource: state.setAlbumSearchLookupSource,
|
||||
setMatchOverrides: state.setMatchOverrides,
|
||||
setSelectedAlbum: state.setSelectedAlbum,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ export interface ImportAlbumResult {
|
|||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
source?: string | null;
|
||||
/** Provider that returned this result row. */
|
||||
source: string;
|
||||
image_url?: string | null;
|
||||
total_tracks?: number | null;
|
||||
release_date?: string | null;
|
||||
|
|
@ -63,6 +64,8 @@ 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;
|
||||
}
|
||||
|
|
@ -72,7 +75,8 @@ export interface ImportTrackResult {
|
|||
name: string;
|
||||
artist: string;
|
||||
album?: string | null;
|
||||
source?: string | null;
|
||||
/** Provider that returned this result row. */
|
||||
source: string;
|
||||
image_url?: string | null;
|
||||
duration_ms?: number | null;
|
||||
}
|
||||
|
|
@ -80,6 +84,8 @@ export interface ImportTrackResult {
|
|||
export interface ImportTrackSearchPayload {
|
||||
success: boolean;
|
||||
tracks?: ImportTrackResult[];
|
||||
/** Provider used to seed the lookup chain for this response. */
|
||||
primary_source?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +93,8 @@ export interface ImportAlbum {
|
|||
id?: string | number | null;
|
||||
name: string;
|
||||
artist: string;
|
||||
source?: string | null;
|
||||
/** Provider used to resolve this selected album. */
|
||||
source: string;
|
||||
image_url?: string | null;
|
||||
total_tracks?: number | null;
|
||||
release_date?: string | null;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ 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 { createAppQueryClient } from '@/app/query-client';
|
||||
import { AppRouterProvider, createAppRouter } from '@/app/router';
|
||||
|
||||
import type { ImportStagingFile } from './-import.types';
|
||||
|
||||
import { resetImportWorkflowStore } from './-import.store';
|
||||
|
||||
function renderImportRoute(initialEntries = ['/import']) {
|
||||
|
|
@ -86,6 +86,7 @@ describe('import route', () => {
|
|||
return HttpResponse.json({
|
||||
success: true,
|
||||
ready: true,
|
||||
primary_source: 'spotify',
|
||||
suggestions: [
|
||||
{
|
||||
id: 'album-1',
|
||||
|
|
@ -101,6 +102,7 @@ describe('import route', () => {
|
|||
http.get('/api/import/search/albums', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
primary_source: 'spotify',
|
||||
albums: [
|
||||
{
|
||||
id: 'album-1',
|
||||
|
|
@ -192,11 +194,17 @@ describe('import route', () => {
|
|||
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('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(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(window.SoulSyncWebShellBridge?.showReactHost).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 () => {
|
||||
renderImportRoute(['/import/auto?autoFilter=pending']);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { type DragEvent, type KeyboardEvent, useState } from 'react';
|
||||
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';
|
||||
|
||||
|
|
@ -15,6 +16,8 @@ import {
|
|||
import {
|
||||
getDisplayedMatchFile,
|
||||
getEffectiveAlbumMatches,
|
||||
getImportSourceBadgeText,
|
||||
getImportSourceFallbackBanner,
|
||||
getTrackDisplayInfo,
|
||||
getUnmatchedStagingFiles,
|
||||
IMPORT_PLACEHOLDER_IMAGE,
|
||||
|
|
@ -47,6 +50,7 @@ function useAlbumImportViewModel() {
|
|||
albumResults,
|
||||
albumSearchError,
|
||||
albumSearchLoading,
|
||||
albumSearchLookupSource,
|
||||
autoGroupFilePaths,
|
||||
clearAutoGroupFilePaths,
|
||||
matchOverrides,
|
||||
|
|
@ -60,6 +64,7 @@ function useAlbumImportViewModel() {
|
|||
setAlbumSearchContext,
|
||||
setAlbumSearchError,
|
||||
setAlbumSearchLoading,
|
||||
setAlbumSearchLookupSource,
|
||||
setMatchOverrides,
|
||||
setSelectedAlbum,
|
||||
} = useAlbumImportWorkflow();
|
||||
|
|
@ -68,7 +73,7 @@ function useAlbumImportViewModel() {
|
|||
setDragOverTrack(null);
|
||||
setTapSelectedChip(null);
|
||||
resetAlbumWorkflow();
|
||||
refreshStaging();
|
||||
void refreshStaging();
|
||||
};
|
||||
|
||||
const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => {
|
||||
|
|
@ -80,6 +85,7 @@ function useAlbumImportViewModel() {
|
|||
try {
|
||||
const payload = await searchImportAlbums(trimmed);
|
||||
setAlbumResults(payload.albums ?? []);
|
||||
setAlbumSearchLookupSource(payload.primary_source ?? null);
|
||||
} catch (error) {
|
||||
setAlbumSearchError(getErrorMessage(error));
|
||||
} finally {
|
||||
|
|
@ -94,6 +100,8 @@ function useAlbumImportViewModel() {
|
|||
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,
|
||||
|
|
@ -165,6 +173,7 @@ function useAlbumImportViewModel() {
|
|||
albumResults,
|
||||
albumSearchError,
|
||||
albumSearchLoading,
|
||||
albumSearchLookupSource,
|
||||
dragOverTrack,
|
||||
groups: groupsQuery.data?.groups ?? [],
|
||||
matchOverrides,
|
||||
|
|
@ -200,6 +209,7 @@ function useAlbumImportViewModel() {
|
|||
stagingFiles,
|
||||
suggestions: suggestionsQuery.data?.suggestions ?? [],
|
||||
suggestionsReady: suggestionsQuery.data?.ready ?? true,
|
||||
suggestionsLookupSource: suggestionsQuery.data?.primary_source ?? null,
|
||||
tapSelectedChip,
|
||||
};
|
||||
}
|
||||
|
|
@ -221,6 +231,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
|||
albumResults,
|
||||
albumSearchError,
|
||||
albumSearchLoading,
|
||||
albumSearchLookupSource,
|
||||
groups,
|
||||
onAlbumQueryChange,
|
||||
onBackToSearch,
|
||||
|
|
@ -230,13 +241,25 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
|||
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 })}>
|
||||
<div
|
||||
id="import-page-album-search-section"
|
||||
className={clsx({ [styles.hidden]: showingMatch })}
|
||||
>
|
||||
{albumResults === null && (
|
||||
<>
|
||||
{groups.length > 0 && (
|
||||
|
|
@ -266,12 +289,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
|||
|
||||
<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}
|
||||
/>
|
||||
))
|
||||
|
|
@ -310,11 +337,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
|||
</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 ? (
|
||||
<div className={styles.importPageEmptyState}>Error: {albumSearchError}</div>
|
||||
<Notice tone="danger" role="alert">
|
||||
Error: {albumSearchError}
|
||||
</Notice>
|
||||
) : albumResults?.length === 0 ? (
|
||||
<div className={styles.importPageEmptyState}>No albums found</div>
|
||||
) : (
|
||||
|
|
@ -322,6 +354,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
|||
<AlbumCard
|
||||
key={`${album.source || 'source'}-${album.id}`}
|
||||
album={album}
|
||||
lookupSource={albumSearchLookupSource}
|
||||
onSelect={onSelectAlbum}
|
||||
/>
|
||||
))
|
||||
|
|
@ -329,11 +362,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
|||
</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 ? (
|
||||
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
||||
) : albumMatchError ? (
|
||||
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div>
|
||||
<Notice tone="danger" role="alert">
|
||||
Error: {albumMatchError}
|
||||
</Notice>
|
||||
) : albumMatch?.album ? (
|
||||
<AlbumMatchPanel viewModel={viewModel} />
|
||||
) : (
|
||||
|
|
@ -348,13 +386,17 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
|||
|
||||
function AlbumCard({
|
||||
album,
|
||||
lookupSource,
|
||||
onSelect,
|
||||
}: {
|
||||
album: ImportAlbumResult;
|
||||
lookupSource: string | null;
|
||||
onSelect: (album: ImportAlbumResult) => void;
|
||||
}) {
|
||||
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
|
||||
|
||||
return (
|
||||
<button className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
|
||||
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
|
||||
<img
|
||||
src={album.image_url || IMPORT_PLACEHOLDER_IMAGE}
|
||||
alt={album.name}
|
||||
|
|
@ -370,6 +412,9 @@ function AlbumCard({
|
|||
<div className={styles.importPageAlbumCardMeta}>
|
||||
{album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
|
||||
</div>
|
||||
{resultSourceBadge ? (
|
||||
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -407,7 +452,9 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
|||
return albumMatchLoading ? (
|
||||
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
||||
) : albumMatchError ? (
|
||||
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div>
|
||||
<Notice tone="danger" role="alert">
|
||||
Error: {albumMatchError}
|
||||
</Notice>
|
||||
) : albumMatch?.album ? (
|
||||
<>
|
||||
<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.importPageMatchTrack}>{trackInfo.name}</span>
|
||||
<span className={clsx(styles.importPageMatchFile, {
|
||||
[styles.hasFile]: file,
|
||||
})}>
|
||||
<span
|
||||
className={clsx(styles.importPageMatchFile, {
|
||||
[styles.hasFile]: file,
|
||||
})}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<span className={styles.importPageMatchFileName}>{file.filename}</span>
|
||||
|
|
|
|||
|
|
@ -237,6 +237,13 @@
|
|||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.importPageAlbumCardSource {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 200, 100, 0.75);
|
||||
margin-top: 2px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Album hero (selected album) */
|
||||
.importPageAlbumHero {
|
||||
display: flex;
|
||||
|
|
@ -639,11 +646,11 @@
|
|||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.importPageSingleResultSelect {
|
||||
padding: 4px 12px;
|
||||
background: rgba(var(--accent-light-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--accent-light-rgb), 0.3);
|
||||
border-radius: 6px;
|
||||
.importPageSingleResultSelect {
|
||||
padding: 4px 12px;
|
||||
background: rgba(var(--accent-light-rgb), 0.15);
|
||||
border: 1px solid rgba(var(--accent-light-rgb), 0.3);
|
||||
border-radius: 6px;
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
|
@ -651,14 +658,14 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.importPageSingleResultSelect:hover {
|
||||
background: rgba(var(--accent-light-rgb), 0.25);
|
||||
}
|
||||
.importPageSingleResultSelect:hover {
|
||||
background: rgba(var(--accent-light-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.importPageEmptyState {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
/* Empty state */
|
||||
.importPageEmptyState {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
@ -821,8 +828,8 @@
|
|||
.importPageMatchRow {
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"num track track"
|
||||
"num file unmatch";
|
||||
'num track track'
|
||||
'num file unmatch';
|
||||
gap: 6px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -853,8 +860,8 @@
|
|||
.importPageSingleItem {
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"checkbox info actions"
|
||||
"search search search";
|
||||
'checkbox info actions'
|
||||
'search search search';
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,8 @@ function ImportProcessingQueue() {
|
|||
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.error]:
|
||||
entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0),
|
||||
[styles.done]: entry.status === 'done',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx';
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { Badge, Button, Checkbox, TextInput } from '@/components/form/form';
|
||||
import { Notice } from '@/components/primitives';
|
||||
|
||||
import type { SingleSearchState } from '../-import.store';
|
||||
import type { ImportTrackResult } from '../-import.types';
|
||||
|
|
@ -113,7 +114,7 @@ export function SinglesImportTab() {
|
|||
});
|
||||
|
||||
clearSinglesSelection();
|
||||
refreshStaging();
|
||||
void refreshStaging();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -295,7 +296,9 @@ function SingleSearchPanel({
|
|||
{searchState?.loading ? (
|
||||
<div className={styles.importPageEmptyState}>Searching...</div>
|
||||
) : searchState?.error ? (
|
||||
<div className={styles.importPageEmptyState}>Error: {searchState.error}</div>
|
||||
<Notice tone="danger" role="alert">
|
||||
Error: {searchState.error}
|
||||
</Notice>
|
||||
) : searchState?.results.length === 0 ? (
|
||||
<div className={styles.importPageEmptyState}>No results found</div>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in a new issue