"use client"; import { CheckIcon, CloudUploadIcon, RotateCwIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type DragEvent, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface UploadTrayFile { name: string; size: number; } type Status = "uploading" | "done" | "error"; interface Upload extends UploadTrayFile { id: string; progress: number; bytesPerSecond: number; status: Status; attempt: number; tilt: number; failAt?: number; } export interface UploadTrayProps { initialFiles?: UploadTrayFile[]; shouldFail?: (file: UploadTrayFile, attempt: number) => boolean; onFiles?: (files: File[]) => void; accept?: string; hint?: string; className?: string; } const KINDS: { match: RegExp; label: string; className: string }[] = [ { match: /\.pdf$/i, label: "PDF", className: "bg-red-600 text-white" }, { match: /\.(png|jpe?g|gif|webp|svg|avif)$/i, label: "IMG", className: "bg-blue-600 text-white" }, { match: /\.(mp4|mov|webm)$/i, label: "VID", className: "bg-foreground text-background" }, { match: /\.(zip|tar|gz|rar)$/i, label: "ZIP", className: "bg-amber-700 text-white" }, { match: /\.(docx?|txt|md|pages)$/i, label: "DOC", className: "bg-sky-700 text-white" }, { match: /\.(csv|xlsx?|numbers)$/i, label: "XLS", className: "bg-emerald-700 text-white" }, { match: /\.(fig|sketch|psd)$/i, label: "FIG", className: "bg-violet-600 text-white" }, ]; function kindOf(name: string) { const known = KINDS.find((k) => k.match.test(name)); if (known) return known; const ext = name.split(".").pop()?.slice(0, 3).toUpperCase() ?? "FILE"; return { label: ext, className: "bg-muted-foreground text-background" }; } function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`; if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; return `${(bytes / 1024 ** 3).toFixed(2)} GB`; } function formatEta(seconds: number) { if (!Number.isFinite(seconds)) return "…"; const s = Math.max(1, Math.ceil(seconds)); if (s < 60) return `${s}s left`; return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s left`; } let uid = 0; const makeUpload = (file: UploadTrayFile): Upload => ({ ...file, id: `u${++uid}`, progress: 0, bytesPerSecond: (0.35 + Math.random() * 0.9) * 1024 ** 2, status: "uploading", attempt: 0, tilt: (Math.random() - 0.5) * 14, }); const TICK = 120; export function UploadTray({ initialFiles = [], shouldFail = () => false, onFiles, accept, hint = "PDF, images, video or archives · up to 50 MB", className, }: UploadTrayProps) { const [uploads, setUploads] = useState([]); const [dragging, setDragging] = useState(false); const depth = useRef(0); const reduce = useReducedMotion(); const inputId = useId(); const shouldFailRef = useRef(shouldFail); useEffect(() => { shouldFailRef.current = shouldFail; }, [shouldFail]); const add = (files: UploadTrayFile[]) => { const next = files.map((file) => { const u = makeUpload(file); return shouldFailRef.current(u, 0) ? { ...u, failAt: 0.35 + Math.random() * 0.3 } : u; }); setUploads((list) => [...next, ...list]); }; const seeded = useRef(false); useEffect(() => { if (seeded.current || initialFiles.length === 0) return; seeded.current = true; const timers = initialFiles.map((file, i) => window.setTimeout(() => add([file]), 250 + i * 260)); return () => { timers.forEach(clearTimeout); seeded.current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const active = uploads.some((u) => u.status === "uploading"); useEffect(() => { if (!active) return; const id = window.setInterval(() => { setUploads((list) => list.map((u) => { if (u.status !== "uploading") return u; const jitter = 0.7 + Math.random() * 0.6; const progress = Math.min(1, u.progress + (u.bytesPerSecond * jitter * TICK) / 1000 / u.size); if (u.failAt !== undefined && progress >= u.failAt) { return { ...u, progress: u.failAt, status: "error", failAt: undefined }; } return { ...u, progress, status: progress >= 1 ? "done" : "uploading" }; }), ); }, TICK); return () => clearInterval(id); }, [active]); const retry = (id: string) => { setUploads((list) => list.map((u) => { if (u.id !== id) return u; const attempt = u.attempt + 1; const failAt = shouldFailRef.current(u, attempt) ? 0.35 + Math.random() * 0.3 : undefined; return { ...u, attempt, failAt, progress: 0, status: "uploading" }; }), ); }; const remove = (id: string) => setUploads((list) => list.filter((u) => u.id !== id)); const takeFiles = (list: FileList | null) => { if (!list?.length) return; const files = [...list]; onFiles?.(files); add(files.map((f) => ({ name: f.name, size: Math.max(f.size, 200_000) }))); }; const onDrag = (event: DragEvent, kind: "enter" | "leave" | "over" | "drop") => { event.preventDefault(); if (kind === "enter") { depth.current += 1; setDragging(true); } else if (kind === "leave") { depth.current = Math.max(0, depth.current - 1); if (depth.current === 0) setDragging(false); } else if (kind === "drop") { depth.current = 0; setDragging(false); takeFiles(event.dataTransfer.files); } }; const done = uploads.filter((u) => u.status === "done").length; return (
{uploads.length > 0 && (
{done} of {uploads.length} uploaded {done > 0 && ( )}
)}
); } function UploadCard({ upload: u, onRetry, onRemove }: { upload: Upload; onRetry: () => void; onRemove: () => void }) { const reduce = useReducedMotion(); const kind = kindOf(u.name); const remaining = (u.size * (1 - u.progress)) / u.bytesPerSecond; return ( {kind.label}
{u.name} {u.status === "uploading" && `${Math.round(u.progress * 100)}%`}
{u.status === "uploading" && ( <> {formatBytes(u.size * u.progress)} of {formatBytes(u.size)} · {formatEta(remaining)} )} {u.status === "done" && <>Uploaded · {formatBytes(u.size)}} {u.status === "error" && Connection lost at {Math.round(u.progress * 100)}%}
{u.status === "done" && ( )} {u.status === "error" && ( Retry )}
); }