"use client"; import { AlertCircle, CheckCircle2, FileArchive, FileAudio, FileCode2, FileIcon, FileImage, FileSpreadsheet, FileText, FileVideo, Loader2, RotateCcw, UploadCloud, X, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useCallback, useId, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type FileUploadStatus = "queued" | "uploading" | "success" | "error"; export type FileUploadVariant = "default" | "centered"; export type FileUploadItem = { id: string; name: string; size: number; type?: string; progress?: number; status?: FileUploadStatus; error?: string; file?: File; }; export type FileUploadClassNames = { root?: string; dropzone?: string; queue?: string; item?: string; leading?: string; content?: string; name?: string; meta?: string; progress?: string; action?: string; }; export interface FileUploadProps { value?: FileUploadItem[]; defaultValue?: FileUploadItem[]; onValueChange?: (items: FileUploadItem[]) => void; onFilesAdded?: (items: FileUploadItem[], files: File[]) => void; onRemove?: (item: FileUploadItem) => void; onRetry?: (item: FileUploadItem) => void; accept?: string; multiple?: boolean; maxFiles?: number; disabled?: boolean; variant?: FileUploadVariant; title?: string; description?: string; browseLabel?: string; className?: string; classNames?: FileUploadClassNames; } const ROW_TRANSITION = { duration: 0.22, ease: EASE_OUT } as const; const FAST_TRANSITION = { duration: 0.16, ease: EASE_OUT } as const; const STATUS_LABEL: Record = { queued: "Queued", uploading: "Uploading", success: "Uploaded", error: "Failed", }; const STATUS_TONE: Record = { queued: "text-muted-foreground", uploading: "text-foreground", success: "text-emerald-600 dark:text-emerald-400", error: "text-destructive", }; function useControllableUpload({ value, defaultValue, onValueChange, }: { value?: FileUploadItem[]; defaultValue?: FileUploadItem[]; onValueChange?: (items: FileUploadItem[]) => void; }) { const [internalValue, setInternalValue] = useState(defaultValue ?? []); const isControlled = value !== undefined; const items = value ?? internalValue; const setItems = useCallback( (next: FileUploadItem[]) => { if (!isControlled) { setInternalValue(next); } onValueChange?.(next); }, [isControlled, onValueChange], ); return [items, setItems] as const; } function clampProgress(value: number | undefined, status: FileUploadStatus) { if (status === "success") return 100; if (value === undefined || Number.isNaN(value)) return 0; return Math.max(0, Math.min(100, value)); } function formatBytes(bytes: number) { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const exponent = Math.min( Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1, ); const value = bytes / 1024 ** exponent; return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${ units[exponent] }`; } function fileKind(item: FileUploadItem) { const extension = item.name.includes(".") ? item.name.split(".").pop() : undefined; if (extension) return extension.toUpperCase(); if (item.type) return item.type.split("/").pop()?.toUpperCase(); return "FILE"; } function getFileIcon(item: FileUploadItem) { const extension = item.name.includes(".") ? item.name.split(".").pop()?.toLowerCase() : undefined; const type = item.type ?? ""; if (type.startsWith("image/")) return FileImage; if (type.startsWith("video/")) return FileVideo; if (type.startsWith("audio/")) return FileAudio; if ( type.includes("zip") || type.includes("compressed") || ["zip", "rar", "7z", "tar", "gz"].includes(extension ?? "") ) { return FileArchive; } if ( type.includes("spreadsheet") || type.includes("excel") || ["csv", "xls", "xlsx"].includes(extension ?? "") ) { return FileSpreadsheet; } if ( type.includes("pdf") || type.startsWith("text/") || ["pdf", "doc", "docx", "md", "txt"].includes(extension ?? "") ) { return FileText; } if ( [ "css", "html", "js", "jsx", "json", "mdx", "ts", "tsx", "xml", "yaml", "yml", ].includes(extension ?? "") ) { return FileCode2; } return FileIcon; } export function createFileUploadItem(file: File, index = 0): FileUploadItem { return { id: `${Date.now()}-${index}-${file.name}`, name: file.name, size: file.size, type: file.type, progress: 0, status: "uploading", file, }; } function StatusIcon({ status, reduce, }: { status: FileUploadStatus; reduce: boolean; }) { const iconClassName = "h-4 w-4"; return ( {status === "success" ? ( ) : status === "error" ? ( ) : status === "uploading" ? ( ) : ( )} {STATUS_LABEL[status]} ); } function FileUploadRow({ item, onRemove, onRetry, classNames, }: { item: FileUploadItem; onRemove: (item: FileUploadItem) => void; onRetry: (item: FileUploadItem) => void; classNames?: FileUploadClassNames; }) { const reduce = useReducedMotion() ?? false; const status = item.status ?? "queued"; const progress = clampProgress(item.progress, status); const progressRatio = progress / 100; const showProgress = status === "uploading" || status === "success"; const LeadingIcon = getFileIcon(item); return (

{item.name}

{fileKind(item)} · {formatBytes(item.size)} {status === "error" && item.error ? ` · ${item.error}` : null}

{status === "error" ? ( ) : null}
{showProgress ? (
) : null}
); } export function FileUpload({ value, defaultValue, onValueChange, onFilesAdded, onRemove, onRetry, accept, multiple = true, maxFiles, disabled = false, variant = "default", title = "Drop files here", description = "Add files to the upload queue", browseLabel = "Browse", className, classNames, }: FileUploadProps) { const inputId = useId(); const inputRef = useRef(null); const dragDepthRef = useRef(0); const reduce = useReducedMotion() ?? false; const [items, setItems] = useControllableUpload({ value, defaultValue, onValueChange, }); const [dragging, setDragging] = useState(false); const commit = useCallback( (next: FileUploadItem[]) => { setItems(next); }, [setItems], ); const addFiles = useCallback( (incomingFiles: File[]) => { if (disabled || incomingFiles.length === 0) return; const remainingSlots = maxFiles === undefined ? incomingFiles.length : maxFiles - items.length; if (remainingSlots <= 0) return; const files = incomingFiles.slice( 0, multiple ? remainingSlots : Math.min(1, remainingSlots), ); const added = files.map((file, index) => createFileUploadItem(file, index)); if (added.length === 0) return; commit([...items, ...added]); onFilesAdded?.(added, files); }, [commit, disabled, items, maxFiles, multiple, onFilesAdded], ); const removeItem = useCallback( (item: FileUploadItem) => { commit(items.filter((entry) => entry.id !== item.id)); onRemove?.(item); }, [commit, items, onRemove], ); const retryItem = useCallback( (item: FileUploadItem) => { const retryingItem = { ...item, error: undefined, progress: 0, status: "uploading" as const, }; commit( items.map((entry) => (entry.id === item.id ? retryingItem : entry)), ); onRetry?.(retryingItem); }, [commit, items, onRetry], ); const resetDrag = useCallback(() => { dragDepthRef.current = 0; setDragging(false); }, []); const maxReached = maxFiles !== undefined && items.length >= maxFiles; const centered = variant === "centered"; return (
{ addFiles(Array.from(event.currentTarget.files ?? [])); event.currentTarget.value = ""; }} />
{items.map((item) => ( ))}
); }