feat(storage): wire Storage Maintenance screen (A6)
Duplicates / failed / missing-metadata tabs (paginated, reusing TrackRow) plus admin orphan-file cleanup. Adds getDuplicates/getBrokenFiles/getMissingMetadata/ runCleanup endpoints and a DuplicateGroup type; drops the dead scanStorage/ deleteTrackFile stubs that had no backend route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,232 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
import { Window, Card, Button, SegmentedControl, Callout } from '@olly/modern-sk';
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||
import {
|
||||
useGetDuplicatesQuery,
|
||||
useGetBrokenFilesQuery,
|
||||
useGetMissingMetadataQuery,
|
||||
useRunCleanupMutation,
|
||||
} from '../../api/endpoints/storage';
|
||||
|
||||
/** `/storage/maintenance` — A6 maintenance (dupes, broken files, cleanup). Scaffold only. */
|
||||
const PAGE_SIZE = 50;
|
||||
type Tab = 'duplicates' | 'broken' | 'missing';
|
||||
|
||||
/** `/storage/maintenance` — A6 maintenance: duplicates, broken files, missing
|
||||
* metadata, and orphan cleanup. */
|
||||
export function StorageMaintenancePage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.storageMaintenance')} />;
|
||||
const offline = useIsOffline();
|
||||
const [tab, setTab] = useState<Tab>('duplicates');
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||
<Window title={t('pages.storageMaintenance')}>
|
||||
<p style={{ color: 'var(--color-text-2)', marginTop: 0 }}>
|
||||
{t('storage.maintenance.subtitle')}
|
||||
</p>
|
||||
|
||||
{offline && (
|
||||
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||
)}
|
||||
|
||||
<CleanupSection />
|
||||
|
||||
<div style={{ margin: '1.25rem 0' }}>
|
||||
<SegmentedControl
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as Tab)}
|
||||
items={[
|
||||
{ value: 'duplicates', label: t('storage.maintenance.tabs.duplicates') },
|
||||
{ value: 'broken', label: t('storage.maintenance.tabs.broken') },
|
||||
{ value: 'missing', label: t('storage.maintenance.tabs.missing') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Only the active tab is mounted, so only its query fires. */}
|
||||
{tab === 'duplicates' && <DuplicatesTab />}
|
||||
{tab === 'broken' && (
|
||||
<TrackListTab useQuery={useGetBrokenFilesQuery} emptyKey="broken" />
|
||||
)}
|
||||
{tab === 'missing' && (
|
||||
<TrackListTab useQuery={useGetMissingMetadataQuery} emptyKey="missing" />
|
||||
)}
|
||||
</Window>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Orphan-file cleanup — admin only (enqueues a worker job). */
|
||||
function CleanupSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = usePermissions();
|
||||
const [runCleanup, { isLoading }] = useRunCleanupMutation();
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const handle = async () => {
|
||||
setDone(false);
|
||||
await runCleanup().unwrap();
|
||||
setDone(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
padding: '1rem 1.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{t('storage.maintenance.cleanup.title')}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('storage.maintenance.cleanup.description')}
|
||||
</div>
|
||||
</div>
|
||||
{done && (
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-accent)' }}>
|
||||
{t('storage.maintenance.cleanup.enqueued')}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ember" disabled={isLoading} onClick={() => void handle()}>
|
||||
{t('storage.maintenance.cleanup.button')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Duplicate groups (tracks sharing an acoustic fingerprint). */
|
||||
function DuplicatesTab() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, refetch } = useGetDuplicatesQuery();
|
||||
|
||||
if (isLoading) return <LoadingSkeleton rows={5} height={56} />;
|
||||
if (isError) return <ErrorState onRetry={refetch} />;
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyState icon="✓" title={t('storage.maintenance.duplicates.empty')} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{data.map((group) => (
|
||||
<Card key={group.fingerprint} style={{ padding: '0.75rem 1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{t('storage.maintenance.duplicates.groupTitle', {
|
||||
count: group.tracks.length,
|
||||
})}
|
||||
</div>
|
||||
{group.tracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
showAlbum
|
||||
onEditMetadata={(tr) => void navigate(`/tracks/${tr.id}/metadata`)}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A paginated track list backed by a maintenance query (broken / missing). */
|
||||
function TrackListTab({
|
||||
useQuery,
|
||||
emptyKey,
|
||||
}: {
|
||||
useQuery:
|
||||
| typeof useGetBrokenFilesQuery
|
||||
| typeof useGetMissingMetadataQuery;
|
||||
emptyKey: 'broken' | 'missing';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [offset, setOffset] = useState(0);
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingSkeleton rows={6} height={56} />;
|
||||
if (isError) return <ErrorState onRetry={refetch} />;
|
||||
if (!data || data.items.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="✓"
|
||||
title={t(`storage.maintenance.${emptyKey}.empty`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const from = offset + 1;
|
||||
const to = offset + data.items.length;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
{data.items.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
showAlbum
|
||||
onEditMetadata={(tr) => void navigate(`/tracks/${tr.id}/metadata`)}
|
||||
/>
|
||||
))}
|
||||
{(offset > 0 || data.hasMore) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.75rem',
|
||||
marginTop: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('storage.maintenance.pagination', {
|
||||
from,
|
||||
to,
|
||||
total: data.total,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
{t('storage.maintenance.prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!data.hasMore}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
{t('storage.maintenance.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user