"use client"; import * as React from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; gsap.registerPlugin(useGSAP); export type FileUploadStatus = | "queued" | "uploading" | "success" | "error" | "cancelled"; export type FileUploadLayout = "full" | "compact"; export interface FileUploadItem { id: string; file: File; progress: number; status: FileUploadStatus; error?: string; retryable?: boolean; } export interface FileUploadProps { uploadFile: ( file: File, onProgress: (progress: number) => void, signal: AbortSignal, ) => Promise; accept?: string; multiple?: boolean; maxFiles?: number; maxSize?: number; layout?: FileUploadLayout; accent?: string; disabled?: boolean; className?: string; onFilesChange?: (items: FileUploadItem[]) => void; } const formatBytes = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const acceptsFile = (file: File, accept?: string) => { if (!accept) return true; return accept.split(",").some((entry) => { const rule = entry.trim().toLowerCase(); if (!rule) return false; if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule); if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1)); return file.type.toLowerCase() === rule; }); }; const createId = () => typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; function UploadIcon({ open, compact = false, accent, }: { open: boolean; compact?: boolean; accent: string; }) { const iconRef = React.useRef(null); const flapRef = React.useRef(null); const mouthRef = React.useRef(null); const shadowRef = React.useRef(null); useGSAP( () => { const flap = flapRef.current; const mouth = mouthRef.current; const shadow = shadowRef.current; if (!flap || !mouth || !shadow) return; const reduceMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; const duration = reduceMotion ? 0 : open ? 0.34 : 0.3; gsap.to(flap, { y: open ? 18 : 0, scaleY: open ? 0.58 : 1, transformOrigin: "50% 100%", duration, ease: open ? "back.out(1.7)" : "power3.inOut", overwrite: "auto", }); gsap.to(mouth, { opacity: open ? 1 : 0, y: open ? 0 : 5, duration: reduceMotion ? 0 : open ? 0.18 : 0.26, ease: "power2.inOut", overwrite: "auto", }); gsap.to(shadow, { x: open ? 1 : 0, y: open ? 2 : 0, duration, ease: "power3.inOut", overwrite: "auto", }); }, { scope: iconRef, dependencies: [open] }, ); return ( ); } function FileRow({ item, accent, onCancel, onRetry, onRemove, }: { item: FileUploadItem; accent: string; onCancel: () => void; onRetry: () => void; onRemove: () => void; }) { const uploading = item.status === "uploading" || item.status === "queued"; return (
  • {item.file.name}

    {formatBytes(item.file.size)} {item.status === "success" && " · Uploaded"} {item.status === "cancelled" && " · Cancelled"}

    {item.status === "success" ? "100%" : `${Math.round(item.progress)}%`} {uploading && } {(item.status === "error" || item.status === "cancelled") && item.retryable && ( )} {!uploading && }
    {item.error &&

    {item.error}

    }
  • ); } export function FileUpload({ uploadFile, accept, multiple = true, maxFiles = 5, maxSize = 10 * 1024 * 1024, layout = "full", accent = "#F97316", disabled = false, className, onFilesChange, }: FileUploadProps) { const compact = layout === "compact"; const inputRef = React.useRef(null); const controllersRef = React.useRef(new Map()); const mountedRef = React.useRef(true); const dragDepthRef = React.useRef(0); const [items, setItems] = React.useState([]); const [dragging, setDragging] = React.useState(false); const [iconOpen, setIconOpen] = React.useState(false); React.useEffect(() => { onFilesChange?.(items); }, [items, onFilesChange]); React.useEffect(() => { mountedRef.current = true; const controllers = controllersRef.current; return () => { mountedRef.current = false; controllers.forEach((controller) => controller.abort()); }; }, []); const openIcon = React.useCallback(() => { if (!disabled) setIconOpen(true); }, [disabled]); const updateItem = React.useCallback((id: string, patch: Partial) => { if (!mountedRef.current) return; setItems((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item)); }, []); const startUpload = React.useCallback(async (item: FileUploadItem) => { const controller = new AbortController(); controllersRef.current.set(item.id, controller); updateItem(item.id, { status: "uploading", progress: 0, error: undefined, retryable: true }); try { await uploadFile( item.file, (progress) => updateItem(item.id, { progress: Math.min(100, Math.max(0, progress)) }), controller.signal, ); if (!controller.signal.aborted) updateItem(item.id, { status: "success", progress: 100, error: undefined }); } catch (error) { if (controller.signal.aborted) { updateItem(item.id, { status: "cancelled", error: "Upload cancelled.", retryable: true }); } else { updateItem(item.id, { status: "error", error: error instanceof Error ? error.message : "Upload failed. Try again.", retryable: true, }); } } finally { controllersRef.current.delete(item.id); } }, [updateItem, uploadFile]); const addFiles = React.useCallback((files: File[]) => { if (disabled || files.length === 0) return; const available = Math.max(0, maxFiles - items.length); const selected = multiple ? files : files.slice(0, 1); const next = selected.map((file, index) => { const base = { id: createId(), file, progress: 0 }; if (index >= available) return { ...base, status: "error", error: `You can upload up to ${maxFiles} files.`, retryable: false }; if (file.size > maxSize) return { ...base, status: "error", error: `File exceeds the ${formatBytes(maxSize)} limit.`, retryable: false }; if (!acceptsFile(file, accept)) return { ...base, status: "error", error: "This file type is not accepted.", retryable: false }; return { ...base, status: "queued", retryable: true }; }); setItems((current) => [...current, ...next]); next.filter((item) => item.status === "queued").forEach(startUpload); if (inputRef.current) inputRef.current.value = ""; }, [accept, disabled, items.length, maxFiles, maxSize, multiple, startUpload]); const cancel = (id: string) => controllersRef.current.get(id)?.abort(); const retry = (item: FileUploadItem) => startUpload(item); const remove = (id: string) => { controllersRef.current.get(id)?.abort(); controllersRef.current.delete(id); setItems((current) => current.filter((item) => item.id !== id)); }; const openPicker = () => !disabled && inputRef.current?.click(); return (
    addFiles(Array.from(event.target.files ?? []))} />
    { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPicker(); } }} onPointerEnter={openIcon} onPointerLeave={() => setIconOpen(false)} onFocus={openIcon} onBlur={() => setIconOpen(false)} onDragEnter={(event) => { event.preventDefault(); if (disabled) return; dragDepthRef.current += 1; setDragging(true); openIcon(); }} onDragOver={(event) => event.preventDefault()} onDragLeave={(event) => { event.preventDefault(); dragDepthRef.current -= 1; if (dragDepthRef.current <= 0) { dragDepthRef.current = 0; setDragging(false); setIconOpen(false); } }} onDrop={(event) => { event.preventDefault(); dragDepthRef.current = 0; setDragging(false); setIconOpen(false); addFiles(Array.from(event.dataTransfer.files)); }} style={ iconOpen ? { borderColor: accent, boxShadow: dragging ? `0 0 0 3px ${accent}22` : undefined, } : undefined } className={cn( "group rounded-2xl border-2 [border-style:dotted] border-border bg-card/55 text-center outline-none transition-[border-color,background-color,box-shadow,padding] duration-200", compact ? "px-4 py-3" : "px-6 py-7", "hover:border-foreground/45 hover:bg-card focus-visible:border-foreground/55 focus-visible:ring-2 focus-visible:ring-foreground/20", dragging && "bg-foreground/3", disabled && "pointer-events-none opacity-45", )} >

    {items.length ? "Add more files" : compact ? "Add files" : "Drop files here to upload"}

    {maxFiles} files · {formatBytes(maxSize)} max

    {items.length > 0 && (
      {items.map((item) => ( cancel(item.id)} onRetry={() => retry(item)} onRemove={() => remove(item.id)} /> ))}
    )}
    {items.map((item) => `${item.file.name}: ${item.status}`).join(". ")}
    ); } FileUpload.displayName = "FileUpload"; export default FileUpload;