// Dependencies: react ^18, lucide-react
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Upload, X, CheckCircle2, AlertCircle, File } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
export type UploadItem = {
id: string;
name: string;
size: number;
status: 'uploading' | 'complete' | 'error';
progress: number;
};
type Props = {
accept?: string;
maxSizeMb?: number;
initialItems?: UploadItem[];
};
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FileUploadDropzone({ accept, maxSizeMb, initialItems = [] }: Props) {
const [items, setItems] = useState<UploadItem[]>(initialItems);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const hasUploading = items.some((i) => i.status === 'uploading');
useEffect(() => {
if (!hasUploading) return;
const timer = setInterval(() => {
setItems((prev) =>
prev.map((item): UploadItem => {
if (item.status !== 'uploading') return item;
const next = Math.min(item.progress + Math.random() * 15 + 5, 100);
if (next >= 100) return { ...item, progress: 100, status: 'complete' };
return { ...item, progress: next };
})
);
}, 300);
return () => clearInterval(timer);
}, [hasUploading]);
const addFiles = useCallback((files: FileList | null) => {
if (!files) return;
setItems((prev) => [
...prev,
...Array.from(files).map((f) => ({
id: `${Date.now()}-${Math.random()}`,
name: f.name,
size: f.size,
status: 'uploading' as const,
progress: 0,
})),
]);
}, []);
const remove = (id: string) => setItems((prev) => prev.filter((i) => i.id !== id));
return (
<div className="w-full flex flex-col gap-3">
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-8 cursor-pointer transition-colors select-none',
dragging ? 'border-indigo-500 bg-indigo-500/5' : 'border-[#2a2a2a] bg-[#0d0d0d] hover:border-zinc-600 hover:bg-zinc-900/40',
)}
>
<input ref={inputRef} type="file" multiple accept={accept} className="hidden" onChange={(e) => addFiles(e.target.files)} />
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center transition-colors', dragging ? 'bg-indigo-500/20' : 'bg-zinc-800')}>
<Upload className={cn('w-5 h-5 transition-colors', dragging ? 'text-indigo-400' : 'text-zinc-400')} />
</div>
<div className="text-center">
<p className={cn('text-sm font-medium transition-colors', dragging ? 'text-indigo-300' : 'text-zinc-300')}>
{dragging ? 'Drop files here' : 'Drag files here or click to browse'}
</p>
{maxSizeMb && <p className="text-[11px] text-zinc-600 mt-0.5">Max {maxSizeMb} MB per file</p>}
</div>
</div>
{items.length > 0 && (
<div className="flex flex-col gap-2">
{items.map((item) => (
<div key={item.id} className="flex items-center gap-3 rounded-lg border border-[#1a1a1a] bg-[#0a0a0a] px-3 py-2.5">
<File className="w-4 h-4 text-zinc-500 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1.5">
<p className="text-xs font-medium text-zinc-300 truncate">{item.name}</p>
<p className="text-[10px] text-zinc-600 shrink-0">{formatBytes(item.size)}</p>
</div>
{item.status === 'error' ? (
<p className="text-[10px] text-red-400">Upload failed · click × to remove</p>
) : (
<div className="h-1 rounded-full bg-zinc-800 overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-300', item.status === 'complete' ? 'bg-emerald-500' : 'bg-indigo-500')}
style={{ width: `${item.status === 'complete' ? 100 : item.progress}%` }}
/>
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1.5">
{item.status === 'complete' && <CheckCircle2 className="w-4 h-4 text-emerald-400" />}
{item.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
<button
onClick={(e) => { e.stopPropagation(); remove(item.id); }}
className="text-zinc-600 hover:text-zinc-400 transition-colors"
aria-label="Remove file"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
// Usage:
// <FileUploadDropzone accept="image/*,.pdf" maxSizeMb={10} />
// Pre-seed with existing uploads:
// const items = [{ id: '1', name: 'logo.png', size: 45200, status: 'complete', progress: 100 }];
// <FileUploadDropzone initialItems={items} />