refactor(webui): key singles import state by file
- replace index-based singles selection and search state with stable staging file keys\n- keep refreshes from shifting selected rows or open search panels when files are inserted or reordered\n- add a regression test that proves selection stays attached to the intended file across refreshes
This commit is contained in:
parent
89252cf6e4
commit
aa86bedc6e
5 changed files with 177 additions and 92 deletions
|
|
@ -11,6 +11,10 @@ import type {
|
||||||
|
|
||||||
export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png';
|
export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png';
|
||||||
|
|
||||||
|
export function getStagingFileKey(file: ImportStagingFile): string {
|
||||||
|
return file.full_path;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatImportBytes(bytes: number): string {
|
export function formatImportBytes(bytes: number): string {
|
||||||
if (bytes > 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
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`;
|
if (bytes > 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MB`;
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import type {
|
||||||
ImportAlbumResult,
|
ImportAlbumResult,
|
||||||
ImportQueueEntry,
|
ImportQueueEntry,
|
||||||
ImportQueueJob,
|
ImportQueueJob,
|
||||||
|
ImportStagingFile,
|
||||||
ImportTrackResult,
|
ImportTrackResult,
|
||||||
} from './-import.types';
|
} from './-import.types';
|
||||||
|
import { getStagingFileKey } from './-import.helpers';
|
||||||
|
|
||||||
export type SingleSearchState = {
|
export type SingleSearchState = {
|
||||||
query: string;
|
query: string;
|
||||||
|
|
@ -37,10 +39,10 @@ function createInitialWorkflowState() {
|
||||||
albumMatchError: null as string | null,
|
albumMatchError: null as string | null,
|
||||||
albumMatchLoading: false,
|
albumMatchLoading: false,
|
||||||
matchOverrides: {} as Record<number, number>,
|
matchOverrides: {} as Record<number, number>,
|
||||||
selectedSingles: new Set<number>(),
|
selectedSingles: new Set<string>(),
|
||||||
singlesManualMatches: {} as Record<number, ImportTrackResult>,
|
singlesManualMatches: {} as Record<string, ImportTrackResult>,
|
||||||
openSingleSearch: null as number | null,
|
openSingleSearch: null as string | null,
|
||||||
singleSearches: {} as Record<number, SingleSearchState>,
|
singleSearches: {} as Record<string, SingleSearchState>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,34 +113,53 @@ export const useImportWorkflowStore = create(
|
||||||
setMatchOverrides: (updater: StateUpdater<Record<number, number>>) => {
|
setMatchOverrides: (updater: StateUpdater<Record<number, number>>) => {
|
||||||
set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) }));
|
set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) }));
|
||||||
},
|
},
|
||||||
toggleSingle: (index: number) => {
|
toggleSingle: (fileKey: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const selectedSingles = new Set(state.selectedSingles);
|
const selectedSingles = new Set(state.selectedSingles);
|
||||||
if (selectedSingles.has(index)) selectedSingles.delete(index);
|
if (selectedSingles.has(fileKey)) selectedSingles.delete(fileKey);
|
||||||
else selectedSingles.add(index);
|
else selectedSingles.add(fileKey);
|
||||||
return { selectedSingles };
|
return { selectedSingles };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
toggleAllSingles: (fileCount: number) => {
|
toggleAllSingles: (stagingFiles: ImportStagingFile[]) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
selectedSingles:
|
selectedSingles: (() => {
|
||||||
state.selectedSingles.size === fileCount
|
const fileKeys = stagingFiles.map(getStagingFileKey);
|
||||||
? new Set<number>()
|
return state.selectedSingles.size === fileKeys.length &&
|
||||||
: new Set(Array.from({ length: fileCount }, (_, index) => index)),
|
fileKeys.every((key) => state.selectedSingles.has(key))
|
||||||
|
? new Set<string>()
|
||||||
|
: new Set(fileKeys);
|
||||||
|
})(),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
clearSinglesSelection: () => {
|
clearSinglesSelection: () => {
|
||||||
set({
|
set({
|
||||||
selectedSingles: new Set<number>(),
|
selectedSingles: new Set<string>(),
|
||||||
singlesManualMatches: {},
|
singlesManualMatches: {},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setOpenSingleSearch: (openSingleSearch: number | null) => set({ openSingleSearch }),
|
syncSinglesWorkflow: (stagingFiles: ImportStagingFile[]) => {
|
||||||
ensureSingleSearch: (index: number, query: string) => {
|
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) => ({
|
set((state) => ({
|
||||||
singleSearches: {
|
singleSearches: {
|
||||||
...state.singleSearches,
|
...state.singleSearches,
|
||||||
[index]: state.singleSearches[index] ?? {
|
[fileKey]: state.singleSearches[fileKey] ?? {
|
||||||
query,
|
query,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -147,9 +168,9 @@ export const useImportWorkflowStore = create(
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
setSingleSearch: (index: number, updater: StateUpdater<SingleSearchState>) => {
|
setSingleSearch: (fileKey: string, updater: StateUpdater<SingleSearchState>) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.singleSearches[index] ?? {
|
const current = state.singleSearches[fileKey] ?? {
|
||||||
query: '',
|
query: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -158,15 +179,15 @@ export const useImportWorkflowStore = create(
|
||||||
return {
|
return {
|
||||||
singleSearches: {
|
singleSearches: {
|
||||||
...state.singleSearches,
|
...state.singleSearches,
|
||||||
[index]: resolveState(current, updater),
|
[fileKey]: resolveState(current, updater),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
selectSingleMatch: (fileIndex: number, track: ImportTrackResult) => {
|
selectSingleMatch: (fileKey: string, track: ImportTrackResult) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
singlesManualMatches: { ...state.singlesManualMatches, [fileIndex]: track },
|
singlesManualMatches: { ...state.singlesManualMatches, [fileKey]: track },
|
||||||
selectedSingles: new Set(state.selectedSingles).add(fileIndex),
|
selectedSingles: new Set(state.selectedSingles).add(fileKey),
|
||||||
openSingleSearch: null,
|
openSingleSearch: null,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
@ -229,6 +250,7 @@ export function useSinglesImportWorkflow() {
|
||||||
setSingleSearch: state.setSingleSearch,
|
setSingleSearch: state.setSingleSearch,
|
||||||
singleSearches: state.singleSearches,
|
singleSearches: state.singleSearches,
|
||||||
singlesManualMatches: state.singlesManualMatches,
|
singlesManualMatches: state.singlesManualMatches,
|
||||||
|
syncSinglesWorkflow: state.syncSinglesWorkflow,
|
||||||
toggleAllSingles: state.toggleAllSingles,
|
toggleAllSingles: state.toggleAllSingles,
|
||||||
toggleSingleInStore: state.toggleSingle,
|
toggleSingleInStore: state.toggleSingle,
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
|
||||||
import { createAppQueryClient } from '@/app/query-client';
|
import { createAppQueryClient } from '@/app/query-client';
|
||||||
import { AppRouterProvider, createAppRouter } from '@/app/router';
|
import { AppRouterProvider, createAppRouter } from '@/app/router';
|
||||||
|
|
||||||
|
import type { ImportStagingFile } from './-import.types';
|
||||||
import { resetImportWorkflowStore } from './-import.store';
|
import { resetImportWorkflowStore } from './-import.store';
|
||||||
|
|
||||||
function createResponse(body: unknown, status = 200) {
|
function createResponse(body: unknown, status = 200) {
|
||||||
|
|
@ -54,9 +55,30 @@ function getFetchUrls() {
|
||||||
|
|
||||||
describe('import route', () => {
|
describe('import route', () => {
|
||||||
let albumMatchBodies: Record<string, unknown>[];
|
let albumMatchBodies: Record<string, unknown>[];
|
||||||
|
let stagingFilesPayload: ImportStagingFile[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
albumMatchBodies = [];
|
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();
|
resetImportWorkflowStore();
|
||||||
window.SoulSyncWebShellBridge = createShellBridge();
|
window.SoulSyncWebShellBridge = createShellBridge();
|
||||||
window.showToast = vi.fn();
|
window.showToast = vi.fn();
|
||||||
|
|
@ -71,17 +93,7 @@ describe('import route', () => {
|
||||||
return createResponse({
|
return createResponse({
|
||||||
success: true,
|
success: true,
|
||||||
staging_path: '/music/Staging',
|
staging_path: '/music/Staging',
|
||||||
files: [
|
files: 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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,6 +255,38 @@ describe('import route', () => {
|
||||||
expect(await screen.findByDisplayValue('half matched album')).toBeInTheDocument();
|
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.getByLabelText('Select 02-track.flac') as HTMLInputElement).checked).toBe(
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((screen.getByLabelText('Select 01-track.flac') as HTMLInputElement).checked).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Process Selected (1)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves album source details when matching an album', async () => {
|
it('preserves album source details when matching an album', async () => {
|
||||||
renderImportRoute();
|
renderImportRoute();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -629,7 +629,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.importPageSingleCheckboxWrap {
|
.importPageSingleCheckboxWrap {
|
||||||
grid-area: checkbox;
|
grid-column: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|
@ -685,6 +685,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.importPageSingleInfo {
|
.importPageSingleInfo {
|
||||||
|
grid-column: 2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -729,6 +730,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.importPageSingleActions {
|
.importPageSingleActions {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1084,6 +1087,10 @@
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.importPageSingleCheckboxWrap {
|
||||||
|
grid-area: checkbox;
|
||||||
|
}
|
||||||
|
|
||||||
.importPageSingleInfo {
|
.importPageSingleInfo {
|
||||||
grid-area: info;
|
grid-area: info;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import type { SingleSearchState } from '../-import.store';
|
import type { SingleSearchState } from '../-import.store';
|
||||||
import type { ImportTrackResult } from '../-import.types';
|
import type { ImportTrackResult } from '../-import.types';
|
||||||
import type { ImportStagingFile } from '../-import.types';
|
import type { ImportStagingFile } from '../-import.types';
|
||||||
|
|
||||||
import styles from './import-page.module.css';
|
import styles from './import-page.module.css';
|
||||||
import { searchImportTracks } from '../-import.api';
|
import { searchImportTracks } from '../-import.api';
|
||||||
import { formatDuration } from '../-import.helpers';
|
import { formatDuration, getStagingFileKey } from '../-import.helpers';
|
||||||
import { useSinglesImportWorkflow } from '../-import.store';
|
import { useSinglesImportWorkflow } from '../-import.store';
|
||||||
import {
|
import {
|
||||||
fallbackImage,
|
fallbackImage,
|
||||||
|
|
@ -26,32 +28,37 @@ export function SinglesImportTab() {
|
||||||
setSingleSearch,
|
setSingleSearch,
|
||||||
singleSearches,
|
singleSearches,
|
||||||
singlesManualMatches,
|
singlesManualMatches,
|
||||||
|
syncSinglesWorkflow,
|
||||||
toggleAllSingles,
|
toggleAllSingles,
|
||||||
toggleSingleInStore,
|
toggleSingleInStore,
|
||||||
} = useSinglesImportWorkflow();
|
} = useSinglesImportWorkflow();
|
||||||
|
|
||||||
const openSingleSearchPanel = (index: number) => {
|
useEffect(() => {
|
||||||
if (openSingleSearch === index) {
|
syncSinglesWorkflow(stagingFiles);
|
||||||
|
}, [stagingFiles, syncSinglesWorkflow]);
|
||||||
|
|
||||||
|
const openSingleSearchPanel = (file: ImportStagingFile) => {
|
||||||
|
const fileKey = getStagingFileKey(file);
|
||||||
|
if (openSingleSearch === fileKey) {
|
||||||
setOpenSingleSearch(null);
|
setOpenSingleSearch(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenSingleSearch(index);
|
setOpenSingleSearch(fileKey);
|
||||||
const file = stagingFiles[index];
|
|
||||||
const defaultQuery =
|
const defaultQuery =
|
||||||
[file?.artist, file?.title].filter(Boolean).join(' ') ||
|
[file?.artist, file?.title].filter(Boolean).join(' ') ||
|
||||||
(file?.filename || '').replace(/\.[^.]+$/, '');
|
(file?.filename || '').replace(/\.[^.]+$/, '');
|
||||||
ensureSingleSearch(index, defaultQuery);
|
ensureSingleSearch(fileKey, defaultQuery);
|
||||||
if (defaultQuery && !singleSearches[index]?.results.length) {
|
if (defaultQuery && !singleSearches[fileKey]?.results.length) {
|
||||||
void runSingleSearch(index, defaultQuery);
|
void runSingleSearch(fileKey, defaultQuery);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const runSingleSearch = async (index: number, query: string) => {
|
const runSingleSearch = async (fileKey: string, query: string) => {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
setSingleSearch(index, (current) => ({
|
setSingleSearch(fileKey, (current) => ({
|
||||||
query: trimmed,
|
query: trimmed,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -60,14 +67,14 @@ export function SinglesImportTab() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await searchImportTracks(trimmed);
|
const payload = await searchImportTracks(trimmed);
|
||||||
setSingleSearch(index, {
|
setSingleSearch(fileKey, {
|
||||||
query: trimmed,
|
query: trimmed,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
results: payload.tracks ?? [],
|
results: payload.tracks ?? [],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSingleSearch(index, {
|
setSingleSearch(fileKey, {
|
||||||
query: trimmed,
|
query: trimmed,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
|
|
@ -76,16 +83,15 @@ export function SinglesImportTab() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectSingleMatch = (fileIndex: number, track: ImportTrackResult) => {
|
const selectSingleMatch = (fileKey: string, track: ImportTrackResult) => {
|
||||||
selectSingleMatchInStore(fileIndex, track);
|
selectSingleMatchInStore(fileKey, track);
|
||||||
};
|
};
|
||||||
|
|
||||||
const processSingles = () => {
|
const processSingles = () => {
|
||||||
if (selectedSingles.size === 0) return;
|
const filesToProcess = stagingFiles.flatMap((file) => {
|
||||||
const filesToProcess = Array.from(selectedSingles).flatMap((index) => {
|
const fileKey = getStagingFileKey(file);
|
||||||
const file = stagingFiles[index];
|
if (!selectedSingles.has(fileKey)) return [];
|
||||||
if (!file) return [];
|
const manualMatch = singlesManualMatches[fileKey];
|
||||||
const manualMatch = singlesManualMatches[index];
|
|
||||||
return manualMatch ? [{ ...file, manual_match: manualMatch }] : [file];
|
return manualMatch ? [{ ...file, manual_match: manualMatch }] : [file];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -111,21 +117,21 @@ export function SinglesImportTab() {
|
||||||
<SinglesImportPanel
|
<SinglesImportPanel
|
||||||
files={stagingFiles}
|
files={stagingFiles}
|
||||||
manualMatches={singlesManualMatches}
|
manualMatches={singlesManualMatches}
|
||||||
openSearchIndex={openSingleSearch}
|
openSearchKey={openSingleSearch}
|
||||||
searchStates={singleSearches}
|
searchStates={singleSearches}
|
||||||
selected={selectedSingles}
|
selected={selectedSingles}
|
||||||
onOpenSearch={openSingleSearchPanel}
|
onOpenSearch={openSingleSearchPanel}
|
||||||
onProcessSingles={processSingles}
|
onProcessSingles={processSingles}
|
||||||
onRunSearch={runSingleSearch}
|
onRunSearch={runSingleSearch}
|
||||||
onSearchQueryChange={(index, query) => {
|
onSearchQueryChange={(fileKey, query) => {
|
||||||
setSingleSearch(index, (current) => ({
|
setSingleSearch(fileKey, (current) => ({
|
||||||
query,
|
query,
|
||||||
loading: current.loading,
|
loading: current.loading,
|
||||||
error: current.error,
|
error: current.error,
|
||||||
results: current.results,
|
results: current.results,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
onSelectAll={() => toggleAllSingles(stagingFiles.length)}
|
onSelectAll={() => toggleAllSingles(stagingFiles)}
|
||||||
onSelectMatch={selectSingleMatch}
|
onSelectMatch={selectSingleMatch}
|
||||||
onToggleSingle={toggleSingleInStore}
|
onToggleSingle={toggleSingleInStore}
|
||||||
/>
|
/>
|
||||||
|
|
@ -135,7 +141,7 @@ export function SinglesImportTab() {
|
||||||
export function SinglesImportPanel({
|
export function SinglesImportPanel({
|
||||||
files,
|
files,
|
||||||
manualMatches,
|
manualMatches,
|
||||||
openSearchIndex,
|
openSearchKey,
|
||||||
searchStates,
|
searchStates,
|
||||||
selected,
|
selected,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
|
|
@ -147,19 +153,20 @@ export function SinglesImportPanel({
|
||||||
onToggleSingle,
|
onToggleSingle,
|
||||||
}: {
|
}: {
|
||||||
files: ImportStagingFile[];
|
files: ImportStagingFile[];
|
||||||
manualMatches: Record<number, ImportTrackResult>;
|
manualMatches: Record<string, ImportTrackResult>;
|
||||||
openSearchIndex: number | null;
|
openSearchKey: string | null;
|
||||||
searchStates: Record<number, SingleSearchState>;
|
searchStates: Record<string, SingleSearchState>;
|
||||||
selected: Set<number>;
|
selected: Set<string>;
|
||||||
onOpenSearch: (index: number) => void;
|
onOpenSearch: (file: ImportStagingFile) => void;
|
||||||
onProcessSingles: () => void;
|
onProcessSingles: () => void;
|
||||||
onRunSearch: (index: number, query: string) => void;
|
onRunSearch: (fileKey: string, query: string) => void;
|
||||||
onSearchQueryChange: (index: number, query: string) => void;
|
onSearchQueryChange: (fileKey: string, query: string) => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void;
|
onSelectMatch: (fileKey: string, track: ImportTrackResult) => void;
|
||||||
onToggleSingle: (index: number) => void;
|
onToggleSingle: (fileKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const allSelected = files.length > 0 && selected.size === files.length;
|
const selectedCount = files.filter((file) => selected.has(getStagingFileKey(file))).length;
|
||||||
|
const allSelected = files.length > 0 && selectedCount === files.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -174,28 +181,29 @@ export function SinglesImportPanel({
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageProcessBtn}
|
className={styles.importPageProcessBtn}
|
||||||
id="import-page-singles-process-btn"
|
id="import-page-singles-process-btn"
|
||||||
disabled={selected.size === 0}
|
disabled={selectedCount === 0}
|
||||||
onClick={onProcessSingles}
|
onClick={onProcessSingles}
|
||||||
>
|
>
|
||||||
Process Selected ({selected.size})
|
Process Selected ({selectedCount})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.importPageSinglesList} id="import-page-singles-list">
|
<div className={styles.importPageSinglesList} id="import-page-singles-list">
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<div className={styles.importPageEmptyState}>No audio files found in import folder</div>
|
<div className={styles.importPageEmptyState}>No audio files found in import folder</div>
|
||||||
) : (
|
) : (
|
||||||
files.map((file, index) => {
|
files.map((file) => {
|
||||||
const manualMatch = manualMatches[index];
|
const fileKey = getStagingFileKey(file);
|
||||||
const isSelected = selected.has(index);
|
const manualMatch = manualMatches[fileKey];
|
||||||
const searchState = searchStates[index];
|
const isSelected = selected.has(fileKey);
|
||||||
|
const searchState = searchStates[fileKey];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${file.full_path}-${index}`}
|
key={fileKey}
|
||||||
className={`${styles.importPageSingleItem} ${
|
className={`${styles.importPageSingleItem} ${
|
||||||
manualMatch ? styles.matched : ''
|
manualMatch ? styles.matched : ''
|
||||||
}`}
|
}`}
|
||||||
data-single-idx={index}
|
data-single-key={fileKey}
|
||||||
>
|
>
|
||||||
<label className={styles.importPageSingleCheckboxWrap}>
|
<label className={styles.importPageSingleCheckboxWrap}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -203,7 +211,7 @@ export function SinglesImportPanel({
|
||||||
aria-label={`Select ${file.filename}`}
|
aria-label={`Select ${file.filename}`}
|
||||||
className={styles.importPageSingleCheckboxInput}
|
className={styles.importPageSingleCheckboxInput}
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => onToggleSingle(index)}
|
onChange={() => onToggleSingle(fileKey)}
|
||||||
/>
|
/>
|
||||||
<span className={styles.importPageSingleCheckbox} aria-hidden="true" />
|
<span className={styles.importPageSingleCheckbox} aria-hidden="true" />
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -220,7 +228,7 @@ export function SinglesImportPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageSingleMatchedChange}
|
className={styles.importPageSingleMatchedChange}
|
||||||
onClick={() => onOpenSearch(index)}
|
onClick={() => onOpenSearch(file)}
|
||||||
>
|
>
|
||||||
change
|
change
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -231,14 +239,14 @@ export function SinglesImportPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageIdentifyBtn}
|
className={styles.importPageIdentifyBtn}
|
||||||
onClick={() => onOpenSearch(index)}
|
onClick={() => onOpenSearch(file)}
|
||||||
>
|
>
|
||||||
🔍 Identify
|
🔍 Identify
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{openSearchIndex === index ? (
|
{openSearchKey === fileKey ? (
|
||||||
<SingleSearchPanel
|
<SingleSearchPanel
|
||||||
fileIndex={index}
|
fileKey={fileKey}
|
||||||
searchState={searchState}
|
searchState={searchState}
|
||||||
onQueryChange={onSearchQueryChange}
|
onQueryChange={onSearchQueryChange}
|
||||||
onRunSearch={onRunSearch}
|
onRunSearch={onRunSearch}
|
||||||
|
|
@ -255,17 +263,17 @@ export function SinglesImportPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
function SingleSearchPanel({
|
function SingleSearchPanel({
|
||||||
fileIndex,
|
fileKey,
|
||||||
searchState,
|
searchState,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
onRunSearch,
|
onRunSearch,
|
||||||
onSelectMatch,
|
onSelectMatch,
|
||||||
}: {
|
}: {
|
||||||
fileIndex: number;
|
fileKey: string;
|
||||||
searchState: SingleSearchState | undefined;
|
searchState: SingleSearchState | undefined;
|
||||||
onQueryChange: (index: number, query: string) => void;
|
onQueryChange: (fileKey: string, query: string) => void;
|
||||||
onRunSearch: (index: number, query: string) => void;
|
onRunSearch: (fileKey: string, query: string) => void;
|
||||||
onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void;
|
onSelectMatch: (fileKey: string, track: ImportTrackResult) => void;
|
||||||
}) {
|
}) {
|
||||||
const query = searchState?.query ?? '';
|
const query = searchState?.query ?? '';
|
||||||
|
|
||||||
|
|
@ -277,20 +285,20 @@ function SingleSearchPanel({
|
||||||
className={styles.importPageSingleSearchInput}
|
className={styles.importPageSingleSearchInput}
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="Search artist - title..."
|
placeholder="Search artist - title..."
|
||||||
onChange={(event) => onQueryChange(fileIndex, event.target.value)}
|
onChange={(event) => onQueryChange(fileKey, event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter') onRunSearch(fileIndex, query);
|
if (event.key === 'Enter') onRunSearch(fileKey, query);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageSingleSearchGo}
|
className={styles.importPageSingleSearchGo}
|
||||||
onClick={() => onRunSearch(fileIndex, query)}
|
onClick={() => onRunSearch(fileKey, query)}
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.importPageSingleSearchResults} id={`import-single-results-${fileIndex}`}>
|
<div className={styles.importPageSingleSearchResults}>
|
||||||
{searchState?.loading ? (
|
{searchState?.loading ? (
|
||||||
<div className={styles.importPageEmptyState}>Searching...</div>
|
<div className={styles.importPageEmptyState}>Searching...</div>
|
||||||
) : searchState?.error ? (
|
) : searchState?.error ? (
|
||||||
|
|
@ -303,7 +311,7 @@ function SingleSearchPanel({
|
||||||
key={`${track.source || 'source'}-${track.id}-${index}`}
|
key={`${track.source || 'source'}-${track.id}-${index}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.importPageSingleResultItem}
|
className={styles.importPageSingleResultItem}
|
||||||
onClick={() => onSelectMatch(fileIndex, track)}
|
onClick={() => onSelectMatch(fileKey, track)}
|
||||||
>
|
>
|
||||||
{track.image_url ? (
|
{track.image_url ? (
|
||||||
<img
|
<img
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue