feat(upload): wire A8 local track upload to backend
Implement the A8 upload screen against the existing /upload contract:
- UploadResponse type ({track_id, title, already_exists}) + mutation typed to it
- buildUploadFormData helper (single file under field `file`, per FastAPI)
- UploadPage: drag-and-drop + file picker, client-side queue with
concurrency cap (3), per-file status badges, retry on error,
already_exists -> "Already in library", deep-link to A7 metadata editor
- i18n upload.* section (en/ru) incl. "metadata pending" hint
Indeterminate spinner per file; percent progress is a follow-up
(needs an XHR baseQuery — fetchBaseQuery gives no upload progress).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,328 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
|
||||
import {
|
||||
buildUploadFormData,
|
||||
useUploadTrackMutation,
|
||||
} from '../../api/endpoints/upload';
|
||||
|
||||
/** `/upload` — A8 drag-and-drop upload of own files. Scaffold only. */
|
||||
/** Pure client-side state — this is a transient upload queue, never server data. */
|
||||
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
file: File;
|
||||
status: ItemStatus;
|
||||
error?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
|
||||
/** The endpoint takes one file per request, so we cap concurrent requests. */
|
||||
const MAX_CONCURRENCY = 3;
|
||||
|
||||
function extractError(err: unknown): string {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const e = err as { data?: { message?: string; detail?: string }; error?: string };
|
||||
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
||||
}
|
||||
return 'Upload failed';
|
||||
}
|
||||
|
||||
/** `/upload` — A8 drag-and-drop upload of own files. */
|
||||
export function UploadPage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.upload')} />;
|
||||
const navigate = useNavigate();
|
||||
const [uploadTrack] = useUploadTrackMutation();
|
||||
|
||||
const [items, setItems] = useState<QueueItem[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const idCounter = useRef(0);
|
||||
const activeCount = useRef(0);
|
||||
const pending = useRef<QueueItem[]>([]);
|
||||
|
||||
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)));
|
||||
|
||||
// Ref-based concurrency pump: refs (not state) so it is safe to call from
|
||||
// async callbacks without stale closures over the queue.
|
||||
const pump = () => {
|
||||
while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) {
|
||||
const item = pending.current.shift()!;
|
||||
activeCount.current += 1;
|
||||
patchItem(item.id, { status: 'uploading', error: undefined });
|
||||
uploadTrack(buildUploadFormData(item.file))
|
||||
.unwrap()
|
||||
.then((res) => {
|
||||
patchItem(item.id, {
|
||||
status: res.already_exists ? 'duplicate' : 'done',
|
||||
trackId: res.track_id,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
patchItem(item.id, { status: 'error', error: extractError(err) });
|
||||
})
|
||||
.finally(() => {
|
||||
activeCount.current -= 1;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addFiles = (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
const newItems: QueueItem[] = files.map((file) => ({
|
||||
id: `u${idCounter.current++}`,
|
||||
file,
|
||||
status: 'queued',
|
||||
}));
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
pending.current.push(...newItems);
|
||||
pump();
|
||||
};
|
||||
|
||||
const retry = (id: string) => {
|
||||
let target: QueueItem | undefined;
|
||||
setItems((prev) => {
|
||||
target = prev.find((it) => it.id === id);
|
||||
return prev.map((it) =>
|
||||
it.id === id ? { ...it, status: 'queued', error: undefined } : it,
|
||||
);
|
||||
});
|
||||
if (target) {
|
||||
pending.current.push({ ...target, status: 'queued', error: undefined });
|
||||
pump();
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) addFiles(Array.from(e.target.files));
|
||||
e.target.value = ''; // allow re-selecting the same file
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files) addFiles(Array.from(e.dataTransfer.files));
|
||||
};
|
||||
|
||||
const completed = items.filter(
|
||||
(it) => it.status === 'done' || it.status === 'duplicate',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||
{t('upload.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
style={{
|
||||
border: `2px dashed ${
|
||||
dragging ? 'var(--color-accent)' : 'var(--color-border)'
|
||||
}`,
|
||||
borderRadius: 10,
|
||||
padding: '2.5rem 1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
background: dragging ? 'var(--color-surface-2)' : 'transparent',
|
||||
transition: 'background 120ms, border-color 120ms',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '2rem' }}>⬆</div>
|
||||
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('upload.dropzone.hint')}
|
||||
</div>
|
||||
<Button variant="primary" size="sm" type="button">
|
||||
{t('upload.dropzone.button')}
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*"
|
||||
onChange={onInputChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout variant="info">{t('upload.metadataPending')}</Callout>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||
{t('upload.queueTitle', {
|
||||
completed,
|
||||
total: items.length,
|
||||
})}
|
||||
</span>
|
||||
{completed > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setItems((prev) =>
|
||||
prev.filter(
|
||||
(it) =>
|
||||
it.status !== 'done' && it.status !== 'duplicate',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('upload.clearCompleted')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<UploadRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRetry={() => retry(item.id)}
|
||||
onEditMetadata={() =>
|
||||
void navigate(`/tracks/${item.trackId}/metadata`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadRow({
|
||||
item,
|
||||
onRetry,
|
||||
onEditMetadata,
|
||||
}: {
|
||||
item: QueueItem;
|
||||
onRetry: () => void;
|
||||
onEditMetadata: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const done = item.status === 'done' || item.status === 'duplicate';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--color-surface-1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={item.file.name}
|
||||
>
|
||||
{item.file.name}
|
||||
</div>
|
||||
{item.status === 'error' && item.error && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||
{item.error}
|
||||
</div>
|
||||
)}
|
||||
{done && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||
{t('upload.unknownArtist')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StatusBadge status={item.status} />
|
||||
|
||||
{item.status === 'error' && (
|
||||
<Button variant="ghost" size="sm" type="button" onClick={onRetry}>
|
||||
{t('upload.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{done && item.trackId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={onEditMetadata}
|
||||
>
|
||||
{t('upload.editMetadata')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ItemStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'uploading') {
|
||||
return (
|
||||
<Badge variant="neutral">
|
||||
<Spinner size="sm" /> {t('upload.status.uploading')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const cfg: Record<
|
||||
Exclude<ItemStatus, 'uploading'>,
|
||||
{ variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string }
|
||||
> = {
|
||||
queued: { variant: 'neutral', key: 'upload.status.queued' },
|
||||
done: { variant: 'lime', key: 'upload.status.done' },
|
||||
duplicate: { variant: 'outline', key: 'upload.status.duplicate' },
|
||||
error: { variant: 'ember', key: 'upload.status.error' },
|
||||
};
|
||||
const { variant, key } = cfg[status];
|
||||
return (
|
||||
<Badge variant={variant} dot>
|
||||
{t(key)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user