import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk'; import { buildUploadFormData, useUploadTrackMutation, } from '../../api/endpoints/upload'; import { useGetTrackQuery } from '../../api/endpoints/library'; import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge'; /** 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(); 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 && item.trackId && }
{item.status === 'error' && ( )} {done && item.trackId && ( )}
); } /** * Polls a just-uploaded track until enrichment settles, then shows the outcome. * Metadata enrichment runs asynchronously in a worker after the upload response * returns, so without polling the row would never reflect the resolved title/ * artist or a failure reason. Polling stops (interval → 0) once the status * leaves `pending`. */ function EnrichmentStatus({ trackId }: { trackId: string }) { const { t } = useTranslation(); const [pollMs, setPollMs] = useState(2500); const { data } = useGetTrackQuery(trackId, { pollingInterval: pollMs }); useEffect(() => { if (data && data.metadataStatus !== 'pending') setPollMs(0); }, [data]); const status = data?.metadataStatus ?? 'pending'; const resolved = data && data.metadataStatus === 'enriched' ? `${data.artistName} · ${data.title}` : t(`metadata.statusHint.${status}`); return (
{status === 'failed' && data?.metadataError ? data.metadataError : resolved}
); } 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)} ); }