Drag-and-drop upload zone with file previews, progress tracking, and per-file retry. AI output had no drag-over visual feedback, no client-side validation, no URL cleanup, and no individual retry. The production version fixes all six issues found in review.
1export function UploadZone() {2 const dispatch = useAppDispatch();3 const fileInputRef = useRef<HTMLInputElement>(null);45 const handleDrop = useCallback(6 (e: React.DragEvent) => {7 e.preventDefault();8 if (e.dataTransfer.files.length > 0) {9 const files = Array.from(e.dataTransfer.files).map((file) => ({10 id: `file-${Date.now()}-${Math.random().toString(36).slice(2)}`,11 file: { name: file.name, size: file.size, type: file.type },12 }));13 dispatch(addFiles(files));14 }15 },16 [dispatch],17 );1819 const handleDragOver = useCallback((e: React.DragEvent) => {20 e.preventDefault();21 }, []);2223 const handleClick = useCallback(() => {24 fileInputRef.current?.click();25 }, []);2627 const handleFileChange = useCallback(28 (e: React.ChangeEvent<HTMLInputElement>) => {29 if (e.target.files) {30 const files = Array.from(e.target.files).map((file) => ({31 id: `file-${Date.now()}-${Math.random().toString(36).slice(2)}`,32 file: { name: file.name, size: file.size, type: file.type },33 }));34 dispatch(addFiles(files));35 }36 },37 [dispatch],38 );3940 return (41 <div42 className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/25 p-8 cursor-pointer"43 onDragOver={handleDragOver}44 onDrop={handleDrop}45 onClick={handleClick}46 >47 <input ref={fileInputRef} type="file" className="hidden"48 multiple onChange={handleFileChange} />49 <Upload className="h-6 w-6 text-muted-foreground" />50 <p className="text-sm font-medium">51 Drag & drop files here or click to browse52 </p>53 </div>54 );55}1export function UploadZone() {2 const dispatch = useAppDispatch();3 const files = useAppSelector((state) => state.uploads.files);4 const [isDragOver, setIsDragOver] = useState(false);5 const dragCounter = useRef(0);6 const fileInputRef = useRef<HTMLInputElement>(null);78 const activeFileCount = files.filter(9 (f) => f.status === "queued" || f.status === "uploading"10 ).length;11 const isDisabled = activeFileCount >= MAX_FILE_COUNT;1213 const validateAndAddFiles = useCallback(14 (fileList: FileList) => {15 const remainingSlots = MAX_FILE_COUNT - activeFileCount;16 if (remainingSlots <= 0) {17 toast.error("You can upload up to 5 files at once.");18 return;19 }20 const selected = Array.from(fileList).slice(0, remainingSlots);21 const validFiles = [];22 for (const file of selected) {23 if (!ACCEPTED_FILE_TYPES.includes(file.type)) {24 toast.error(`${file.name} has an unsupported file type.`);25 continue;26 }27 if (file.size > MAX_FILE_SIZE) {28 toast.error(`${file.name} exceeds the 10 MB limit.`);29 continue;30 }31 const isImage = ACCEPTED_IMAGE_TYPES.includes(file.type);32 validFiles.push({33 id: `file-${Date.now()}-${Math.random().toString(36).slice(2)}`,34 file: { name: file.name, size: file.size, type: file.type },35 previewUrl: isImage ? URL.createObjectURL(file) : undefined,36 });37 }38 if (validFiles.length > 0) dispatch(addFiles(validFiles));39 },40 [dispatch, activeFileCount],41 );4243 const handleDragEnter = useCallback((e: React.DragEvent) => {44 e.preventDefault();45 if (isDisabled) return;46 dragCounter.current += 1;47 if (dragCounter.current === 1) setIsDragOver(true);48 }, [isDisabled]);4950 const handleDragLeave = useCallback((e: React.DragEvent) => {51 e.preventDefault();52 dragCounter.current -= 1;53 if (dragCounter.current === 0) setIsDragOver(false);54 }, []);5556 const handleDrop = useCallback(57 (e: React.DragEvent) => {58 e.preventDefault();59 dragCounter.current = 0;60 setIsDragOver(false);61 if (!isDisabled && e.dataTransfer.files.length > 0) {62 validateAndAddFiles(e.dataTransfer.files);63 }64 },65 [isDisabled, validateAndAddFiles],66 );6768 return (69 <div70 role="button" tabIndex={isDisabled ? -1 : 0}71 aria-label="Upload files. Drag and drop or click to browse."72 aria-disabled={isDisabled}73 className={cn(74 "flex flex-col items-center gap-3 rounded-lg border-2 border-dashed p-8 cursor-pointer transition-colors",75 isDragOver && !isDisabled76 ? "border-primary bg-primary/5"77 : "border-muted-foreground/25 hover:border-muted-foreground/50",78 isDisabled && "cursor-not-allowed opacity-50",79 )}80 onDragEnter={handleDragEnter}81 onDragLeave={handleDragLeave}82 onDrop={handleDrop}83 onClick={() => !isDisabled && fileInputRef.current?.click()}84 >85 <input ref={fileInputRef} type="file" className="hidden"86 multiple accept={ACCEPTED_FILE_TYPES.join(",")} onChange={handleFileChange} />87 {isDragOver88 ? <FileUp className="h-6 w-6 text-primary" />89 : <Upload className="h-6 w-6 text-muted-foreground" />}90 <p className="text-sm font-medium">91 {isDragOver ? "Drop files here" : "Drag & drop files here or click to browse"}92 </p>93 </div>94 );95}1export function UploadZone() {2 const dispatch = useAppDispatch();3 const fileInputRef = useRef<HTMLInputElement>(null);45 const handleDrop = useCallback(6 (e: React.DragEvent) => {7 e.preventDefault();8 if (e.dataTransfer.files.length > 0) {9 const files = Array.from(e.dataTransfer.files).map((file) => ({10 id: `file-${Date.now()}-${Math.random().toString(36).slice(2)}`,11 file: { name: file.name, size: file.size, type: file.type },12 }));13 dispatch(addFiles(files));14 }15 },16 [dispatch],17 );1819 const handleDragOver = useCallback((e: React.DragEvent) => {20 e.preventDefault();21 }, []);2223 const handleClick = useCallback(() => {24 fileInputRef.current?.click();25 }, []);2627 const handleFileChange = useCallback(28 (e: React.ChangeEvent<HTMLInputElement>) => {29 if (e.target.files) {30 const files = Array.from(e.target.files).map((file) => ({31 id: `file-${Date.now()}-${Math.random().toString(36).slice(2)}`,32 file: { name: file.name, size: file.size, type: file.type },33 }));34 dispatch(addFiles(files));35 }36 },37 [dispatch],38 );3940 return (41 <div42 className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/25 p-8 cursor-pointer"43 onDragOver={handleDragOver}44 onDrop={handleDrop}45 onClick={handleClick}46 >47 <input ref={fileInputRef} type="file" className="hidden"48 multiple onChange={handleFileChange} />49 <Upload className="h-6 w-6 text-muted-foreground" />50 <p className="text-sm font-medium">51 Drag & drop files here or click to browse52 </p>53 </div>54 );55}The drop handler works but the zone appearance never changes when a file is dragged over it. There is no isDragOver state, no dragenter/dragleave handling, and no conditional styling. Users get no visual confirmation that the zone is a valid drop target.
Files are added directly to the queue without checking type or size. Invalid files waste time uploading and produce confusing server errors. Client-side validation must reject files before they enter the Redux store.
The drop zone div has no role, aria-label, or aria-disabled. Screen readers cannot identify it as an interactive element. There is no max file count enforcement or disabled appearance when the limit is reached.
isDragOver state is toggled by dragenter/dragleave events. A dragCounter ref prevents flicker from nested DOM elements. The zone border changes to primary color and gains a tinted background during drag-over.
validateAndAddFiles checks file type against ACCEPTED_FILE_TYPES, size against MAX_FILE_SIZE (10 MB), and count against MAX_FILE_COUNT (5). Invalid files are rejected with descriptive toast errors before entering the store.
The zone has role=button, tabIndex, aria-label, and aria-disabled. When the file count limit is reached, the zone becomes visually muted and non-interactive. Keyboard users can activate it with Enter or Space.