soulsync/webui/src/routes/import/-ui/import-page.tsx
BoulderBadgeDad 8f1ffa7632 import: page shows scan progress + auto-loads when the background scan finishes (#947, phase 2 UI)
Frontend half of the async staging scan. The endpoints now return {scanning, progress} while a
large staging folder is still being scanned in the background; the page surfaces that and fills in
automatically when it completes — no manual refresh, no timeout.

- types: ImportStagingFiles/GroupsPayload gain optional scanning + progress (additive).
- useImportStaging exposes `scanning`/`scanProgress` and, while scanning, polls via a plain
  setInterval(refetch, 1500). Deliberately NOT react-query's refetchInterval — and a plain interval
  that only runs while scanning leaves the normal + error query states completely untouched.
- the header shows "Scanning N of M files…" instead of a count while the scan runs.

vitest: new test asserts the scan-progress header renders from a {scanning} response; typecheck
clean. Note: -route.test.tsx's pre-existing "staging files fail to load" test fails only in the
full-file run (passes in isolation) — verified it also fails on clean dev with all my changes
stashed, so it's a pre-existing test-isolation flake, not from this change.
2026-06-29 09:45:24 -07:00

296 lines
9.6 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, Outlet } from '@tanstack/react-router';
import clsx from 'clsx';
import { Button, Switch } from '@/components/form/form';
import { Show } from '@/components/primitives';
import { useReactPageShell } from '@/platform/shell/route-controllers';
import type { ImportOptionsPayload, ImportQueueEntry } from '../-import.types';
import { importOptionsQueryOptions, IMPORT_QUERY_KEY, saveImportOptions } from '../-import.api';
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, scanning, scanProgress, stagingFiles, stagingPath, stagingQuery } =
useImportStaging();
const isRefreshing = stagingQuery.isRefetching;
const lastRefreshedAt =
stagingQuery.dataUpdatedAt > 0 ? formatShortTime(stagingQuery.dataUpdatedAt) : null;
// While a large staging folder is still scanning (#947), show progress instead of a count.
const fileCountText = scanning
? scanProgress && scanProgress.total > 0
? `Scanning ${scanProgress.scanned} of ${scanProgress.total} files…`
: 'Scanning staging folder…'
: getStagingStatsText(stagingFiles);
return (
<div id="import-page" data-testid="import-page">
<div className={styles.importPageContainer}>
<ImportHeader
error={stagingQuery.error}
fileCountText={fileCountText}
loading={stagingQuery.isLoading}
stagingPath={stagingPath}
refreshing={isRefreshing}
lastRefreshedAt={lastRefreshedAt}
onRefresh={refreshStaging}
/>
<ImportOptions />
<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',
});
}
// The two import behaviour toggles, also in Settings → Import, surfaced here so
// they're visible right where you import. Each writes its `import.*` config key
// on change (POST /api/settings partial-merges, so the rest of config is safe).
function ImportOptions() {
const queryClient = useQueryClient();
const optionsQuery = useQuery(importOptionsQueryOptions());
const saveMutation = useMutation({
mutationFn: saveImportOptions,
onSuccess: () => {
window.showToast?.('Import options saved', 'success');
},
onError: (error: unknown) => {
window.showToast?.(
error instanceof Error ? error.message : 'Could not save import options',
'error',
);
// Re-sync the toggles to the server's actual state after a failed save.
void queryClient.invalidateQueries({
queryKey: [...IMPORT_QUERY_KEY, 'import-options'],
});
},
});
const opts = optionsQuery.data;
if (!opts) return null;
const update = (patch: Partial<ImportOptionsPayload>) => {
const next = { ...opts, ...patch };
// Optimistic: reflect the toggle immediately, the mutation persists it.
queryClient.setQueryData([...IMPORT_QUERY_KEY, 'import-options'], next);
saveMutation.mutate(next);
};
return (
<section className={styles.importOptionsRow} data-testid="import-options">
<div className={styles.importOption}>
<Switch
id="import-quality-filter"
checked={opts.qualityFilterEnabled}
disabled={saveMutation.isPending}
aria-labelledby="import-quality-filter-label"
onCheckedChange={(checked) => update({ qualityFilterEnabled: checked })}
/>
<span
id="import-quality-filter-label"
title="Checks imported files against your Quality Profile only. Turning this off imports files regardless of format/bitrate/bit depth/sample rate; AcoustID verification is separate and is not skipped."
>
Quality profile check on import
</span>
</div>
<div className={styles.importOption}>
<Switch
id="import-folder-artist"
checked={opts.folderArtistOverride}
disabled={saveMutation.isPending}
aria-labelledby="import-folder-artist-label"
onCheckedChange={(checked) => update({ folderArtistOverride: checked })}
/>
<span
id="import-folder-artist-label"
title="Use the top Staging folder as the album artist (good for mixtapes; turn off for mixed piles of songs)."
>
Use folder as artist
</span>
</div>
</section>
);
}
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>
{entry.errors.length > 0 && (
<ul className={styles.importPageQueueErrors}>
{entry.errors.map((err, i) => (
<li key={i} title={err}>
{err}
</li>
))}
</ul>
)}
</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>
);
}