From 61dbb1abd29f7337e69a4a004dc7f74648698867 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sun, 7 Jun 2026 18:47:59 +0300 Subject: [PATCH] feat(upload): wire A8 local track upload to backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/api/endpoints/upload.ts | 14 +- src/api/types.ts | 6 + src/features/upload/UploadPage.tsx | 326 ++++++++++++++++++++++++++++- src/i18n/locales/en.ts | 22 ++ src/i18n/locales/ru.ts | 22 ++ 5 files changed, 385 insertions(+), 5 deletions(-) diff --git a/src/api/endpoints/upload.ts b/src/api/endpoints/upload.ts index a408571..0a1c21d 100644 --- a/src/api/endpoints/upload.ts +++ b/src/api/endpoints/upload.ts @@ -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({ + uploadTrack: build.mutation({ query: (formData) => ({ url: '/upload', method: 'POST', diff --git a/src/api/types.ts b/src/api/types.ts index 60650b6..2b94906 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; diff --git a/src/features/upload/UploadPage.tsx b/src/features/upload/UploadPage.tsx index bd86225..c5c1d4e 100644 --- a/src/features/upload/UploadPage.tsx +++ b/src/features/upload/UploadPage.tsx @@ -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 ; + const navigate = useNavigate(); + const [uploadTrack] = useUploadTrackMutation(); + + const [items, setItems] = useState([]); + const [dragging, setDragging] = useState(false); + + const inputRef = useRef(null); + const idCounter = useRef(0); + const activeCount = useRef(0); + const pending = useRef([]); + + const patchItem = (id: string, patch: Partial) => + 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) => { + 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 ( +
+
+

+ {t('upload.title')} +

+
+ + +
+
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', + }} + > +
+
{t('upload.dropzone.title')}
+
+ {t('upload.dropzone.hint')} +
+ + +
+ + {t('upload.metadataPending')} + + {items.length > 0 && ( +
+
+ + {t('upload.queueTitle', { + completed, + total: items.length, + })} + + {completed > 0 && ( + + )} +
+ {items.map((item) => ( + retry(item.id)} + onEditMetadata={() => + void navigate(`/tracks/${item.trackId}/metadata`) + } + /> + ))} +
+ )} +
+
+
+ ); +} + +function UploadRow({ + item, + onRetry, + onEditMetadata, +}: { + item: QueueItem; + onRetry: () => void; + onEditMetadata: () => void; +}) { + const { t } = useTranslation(); + const done = item.status === 'done' || item.status === 'duplicate'; + return ( +
+
+
+ {item.file.name} +
+ {item.status === 'error' && item.error && ( +
+ {item.error} +
+ )} + {done && ( +
+ {t('upload.unknownArtist')} +
+ )} +
+ + + + {item.status === 'error' && ( + + )} + {done && item.trackId && ( + + )} +
+ ); +} + +function StatusBadge({ status }: { status: ItemStatus }) { + const { t } = useTranslation(); + if (status === 'uploading') { + return ( + + {t('upload.status.uploading')} + + ); + } + const cfg: Record< + Exclude, + { 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 ( + + {t(key)} + + ); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index de8c741..ca753de 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index dfc7250..9b7506a 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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;