feat(upload): batch upload with parallel queue, BatchUploader component
This commit is contained in:
parent
8162d1b419
commit
122eff5c0f
2 changed files with 279 additions and 0 deletions
183
apps/web/src/components/upload/BatchUploader.tsx
Normal file
183
apps/web/src/components/upload/BatchUploader.tsx
Normal 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 };
|
||||
96
apps/web/src/components/upload/useBatchUpload.ts
Normal file
96
apps/web/src/components/upload/useBatchUpload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue