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,9 +1,19 @@
|
|||||||
import { api } from '../index';
|
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({
|
export const uploadApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
uploadTrack: build.mutation<Track, FormData>({
|
uploadTrack: build.mutation<UploadResponse, FormData>({
|
||||||
query: (formData) => ({
|
query: (formData) => ({
|
||||||
url: '/upload',
|
url: '/upload',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ export interface DownloadJob {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
track_id: string;
|
||||||
|
title: string;
|
||||||
|
already_exists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorageStats {
|
export interface StorageStats {
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
usedBytes: number;
|
usedBytes: number;
|
||||||
|
|||||||
@@ -1,8 +1,328 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
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() {
|
export function UploadPage() {
|
||||||
const { t } = useTranslation();
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,28 @@ const en = {
|
|||||||
description: "This screen doesn't exist yet.",
|
description: "This screen doesn't exist yet.",
|
||||||
backToLibrary: 'Back to library',
|
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;
|
} as const;
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -181,6 +181,28 @@ const ru: Translations = {
|
|||||||
description: 'Этого экрана пока нет.',
|
description: 'Этого экрана пока нет.',
|
||||||
backToLibrary: 'Вернуться в библиотеку',
|
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;
|
export default ru;
|
||||||
|
|||||||
Reference in New Issue
Block a user