feat(upload): batch upload with parallel queue, BatchUploader component

This commit is contained in:
senke 2026-02-25 13:37:52 +01:00
parent 8162d1b419
commit 122eff5c0f
2 changed files with 279 additions and 0 deletions

View file

@ -0,0 +1,183 @@
import React, { useCallback, useState, useRef } from 'react';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
import { Progress } from '../ui/progress';
const MAX_PARALLEL = 3;
export interface BatchUploadItem {
id: string;
file: File;
progress: number;
status: 'pending' | 'uploading' | 'completed' | 'error' | 'cancelled';
error?: string;
}
interface BatchUploaderProps {
onFilesSelected: (files: File[]) => void;
onUploadFile: (file: File) => Promise<unknown>;
files: BatchUploadItem[];
onCancelFile: (id: string) => void;
onClearCompleted?: () => void;
onStartUpload: () => void;
isUploading: boolean;
acceptedFormats?: string[];
maxSizeInMB?: number;
}
export function BatchUploader({
onFilesSelected,
onUploadFile,
files,
onCancelFile,
onClearCompleted,
onStartUpload,
isUploading,
acceptedFormats = ['.wav', '.mp3', '.aiff', '.flac', '.zip', '.ogg', '.m4a'],
maxSizeInMB = 500,
}: BatchUploaderProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const maxBytes = maxSizeInMB * 1024 * 1024;
const validateAndAdd = useCallback(
(newFiles: File[]) => {
const valid: File[] = [];
for (const f of newFiles) {
if (f.size > maxBytes) {
setError(`${f.name} exceeds ${maxSizeInMB}MB limit`);
continue;
}
const ext = `.${f.name.split('.').pop()?.toLowerCase()}`;
if (!acceptedFormats.includes(ext)) {
setError(`Format ${ext} not supported for ${f.name}`);
continue;
}
valid.push(f);
}
if (valid.length > 0) {
onFilesSelected(valid);
setError(null);
}
},
[onFilesSelected, maxBytes, maxSizeInMB, acceptedFormats]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
validateAndAdd(Array.from(e.dataTransfer.files));
},
[validateAndAdd]
);
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files;
if (selected && selected.length > 0) {
validateAndAdd(Array.from(selected));
e.target.value = '';
}
},
[validateAndAdd]
);
const pendingCount = files.filter((f) => f.status === 'pending').length;
const completedCount = files.filter((f) => f.status === 'completed').length;
return (
<div className="space-y-4">
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center text-center transition-all duration-[var(--sumi-duration-normal)] cursor-pointer
${isDragOver ? 'border-primary bg-primary/10' : 'border-border/50 bg-muted/30 hover:bg-muted/50'}
`}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
accept={acceptedFormats.join(',')}
onChange={handleFileInput}
/>
<p className="text-sm text-muted-foreground">
Drag & drop files or click to select. Max {maxSizeInMB}MB per file.
</p>
<p className="text-xs text-muted-foreground mt-1">
Accepted: {acceptedFormats.join(', ')}
</p>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{files.length > 0 && (
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{files.length} file(s) {pendingCount} pending {completedCount} done
</span>
{completedCount > 0 && onClearCompleted && (
<Button variant="ghost" size="sm" onClick={onClearCompleted}>
Clear completed
</Button>
)}
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{files.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Progress value={item.progress} className="h-1.5 flex-1" />
<span className="text-xs text-muted-foreground w-8">
{item.status === 'completed' ? 'Done' : item.status === 'error' ? 'Error' : `${Math.round(item.progress)}%`}
</span>
</div>
{item.error && (
<p className="text-xs text-destructive mt-1">{item.error}</p>
)}
</div>
{item.status !== 'completed' && item.status !== 'cancelled' && (
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => onCancelFile(item.id)}
aria-label={`Cancel ${item.file.name}`}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
))}
</div>
{pendingCount > 0 && !isUploading && (
<Button onClick={onStartUpload} className="w-full">
Start upload (max {MAX_PARALLEL} parallel)
</Button>
)}
</div>
)}
</div>
);
}
export type { BatchUploaderProps };

View file

@ -0,0 +1,96 @@
import { useState, useCallback, useRef } from 'react';
import type { BatchUploadItem } from './BatchUploader';
const MAX_PARALLEL = 3;
export function useBatchUpload<T>(
uploadFn: (file: File) => Promise<T>,
options?: { onComplete?: () => void; onError?: (err: unknown) => void }
) {
const [items, setItems] = useState<BatchUploadItem[]>([]);
const [isUploading, setIsUploading] = useState(false);
const cancelRef = useRef<Set<string>>(new Set());
const addFiles = useCallback((files: File[]) => {
const newItems: BatchUploadItem[] = files.map((f) => ({
id: `${f.name}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
file: f,
progress: 0,
status: 'pending' as const,
}));
setItems((prev) => [...prev, ...newItems]);
}, []);
const cancelFile = useCallback((id: string) => {
cancelRef.current.add(id);
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, status: 'cancelled' as const } : i))
);
}, []);
const clearCompleted = useCallback(() => {
setItems((prev) => prev.filter((i) => i.status !== 'completed' && i.status !== 'cancelled'));
}, []);
const startUpload = useCallback(async () => {
const pending = items.filter((i) => i.status === 'pending');
if (pending.length === 0) return;
setIsUploading(true);
const queue = [...pending];
let active = 0;
const processNext = () => {
while (active < MAX_PARALLEL && queue.length > 0) {
const item = queue.shift()!;
if (cancelRef.current.has(item.id)) continue;
active++;
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, status: 'uploading' as const } : i))
);
uploadFn(item.file)
.then(() => {
if (cancelRef.current.has(item.id)) return;
setItems((prev) =>
prev.map((i) =>
i.id === item.id ? { ...i, progress: 100, status: 'completed' as const } : i
)
);
})
.catch((err) => {
if (cancelRef.current.has(item.id)) return;
setItems((prev) =>
prev.map((i) =>
i.id === item.id
? { ...i, status: 'error' as const, error: String(err?.message || err) }
: i
)
);
options?.onError?.(err);
})
.finally(() => {
active--;
if (queue.length === 0 && active === 0) {
setIsUploading(false);
options?.onComplete?.();
} else {
processNext();
}
});
}
};
processNext();
}, [items, uploadFn, options?.onComplete, options?.onError]);
return {
items,
isUploading,
addFiles,
cancelFile,
clearCompleted,
startUpload,
};
}