"use client"; import { File as FileIcon, Upload, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type DragEvent, useId, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface FileUploadProps { /** Controlled list of picked files. */ value?: File[]; /** Starting files when uncontrolled. Default []. */ defaultValue?: File[]; onChange?: (files: File[]) => void; /** MIME types or extensions accepted, passed to the native file input. */ accept?: string; /** Allows picking or dropping more than one file at once. Default true. */ multiple?: boolean; /** Largest a single file may be, in bytes. Larger files are rejected with an inline error. */ maxSize?: number; disabled?: boolean; className?: string; } function formatSize(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`; } /** * A dropzone that also opens the native file picker on click. Picked files * land in a list below it, popping in on arrival, collapsing on remove. * Nothing here uploads anything: it just hands back File objects. */ export function FileUpload({ value, defaultValue = [], onChange, accept, multiple = true, maxSize, disabled = false, className, }: FileUploadProps) { const reduce = useReducedMotion(); const inputId = useId(); const dragCount = useRef(0); const errorTimer = useRef | null>(null); const [uncontrolled, setUncontrolled] = useState(defaultValue); const [dragging, setDragging] = useState(false); const [error, setError] = useState(null); const isControlled = value !== undefined; const files = isControlled ? value : uncontrolled; const setFiles = (next: File[]) => { if (!isControlled) setUncontrolled(next); onChange?.(next); }; const showError = (message: string) => { setError(message); if (errorTimer.current) clearTimeout(errorTimer.current); errorTimer.current = setTimeout(() => setError(null), 3000); }; const addFiles = (incoming: File[]) => { const accepted: File[] = []; let oversized: File | undefined; for (const file of incoming) { if (maxSize && file.size > maxSize) { oversized = file; continue; } accepted.push(file); } if (oversized) showError(`${oversized.name} is larger than ${formatSize(maxSize as number)}.`); if (accepted.length === 0) return; setFiles(multiple ? [...files, ...accepted] : accepted.slice(0, 1)); }; const onDrop = (event: DragEvent) => { event.preventDefault(); dragCount.current = 0; setDragging(false); if (!disabled) addFiles(Array.from(event.dataTransfer.files)); }; const onDragEnter = (event: DragEvent) => { event.preventDefault(); dragCount.current += 1; if (!disabled) setDragging(true); }; const onDragLeave = (event: DragEvent) => { event.preventDefault(); dragCount.current = Math.max(0, dragCount.current - 1); if (dragCount.current === 0) setDragging(false); }; const remove = (index: number) => setFiles(files.filter((_, i) => i !== index)); return (
{error ?

{error}

: null} {files.length > 0 ? (
    {files.map((file, index) => ( ))}
) : null}
); }