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:
Senko-san
2026-06-07 18:47:59 +03:00
parent aed0572071
commit 61dbb1abd2
5 changed files with 385 additions and 5 deletions
+12 -2
View File
@@ -1,9 +1,19 @@
import { api } from '../index';
import type { Track } from '../types';
import type { UploadResponse } from '../types';
/**
* Build the multipart body for `/upload`. The backend expects exactly one file
* per request under the field name `file` (anything else → FastAPI 422).
*/
export function buildUploadFormData(file: File): FormData {
const fd = new FormData();
fd.append('file', file);
return fd;
}
export const uploadApi = api.injectEndpoints({
endpoints: (build) => ({
uploadTrack: build.mutation<Track, FormData>({
uploadTrack: build.mutation<UploadResponse, FormData>({
query: (formData) => ({
url: '/upload',
method: 'POST',
+6
View File
@@ -72,6 +72,12 @@ export interface DownloadJob {
updatedAt: string;
}
export interface UploadResponse {
track_id: string;
title: string;
already_exists: boolean;
}
export interface StorageStats {
totalBytes: number;
usedBytes: number;
+323 -3
View File
@@ -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>
);
}
+22
View File
@@ -179,6 +179,28 @@ const en = {
description: "This screen doesn't exist yet.",
backToLibrary: 'Back to library',
},
upload: {
title: 'Upload files',
dropzone: {
title: 'Drag & drop audio files here',
hint: 'or click to choose files — one or many at a time',
button: 'Choose files',
},
queueTitle: 'Uploads ({{completed}}/{{total}})',
clearCompleted: 'Clear completed',
retry: 'Retry',
editMetadata: 'Edit metadata',
metadataPending:
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
unknownArtist: 'Unknown Artist · metadata pending',
status: {
queued: 'Queued',
uploading: 'Uploading',
done: 'Uploaded',
duplicate: 'Already in library',
error: 'Failed',
},
},
} as const;
export default en;
+22
View File
@@ -181,6 +181,28 @@ const ru: Translations = {
description: 'Этого экрана пока нет.',
backToLibrary: 'Вернуться в библиотеку',
},
upload: {
title: 'Загрузка файлов',
dropzone: {
title: 'Перетащите аудиофайлы сюда',
hint: 'или нажмите, чтобы выбрать файлы — по одному или сразу несколько',
button: 'Выбрать файлы',
},
queueTitle: 'Загрузки ({{completed}}/{{total}})',
clearCompleted: 'Убрать завершённые',
retry: 'Повторить',
editMetadata: 'Изменить метаданные',
metadataPending:
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
unknownArtist: 'Unknown Artist · метаданные в ожидании',
status: {
queued: 'В очереди',
uploading: 'Загрузка',
done: 'Загружено',
duplicate: 'Уже в библиотеке',
error: 'Ошибка',
},
},
};
export default ru;