"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AlertCircle, Check, File as FileIcon, FileArchive, FileText, Film, Image as ImageIcon, Music, RotateCcw, UploadCloud, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type UploadStatus = "uploading" | "done" | "error" | "rejected";
export type UploadItem = {
id: string;
file: File;
status: UploadStatus;
progress: number;
error?: string;
previewUrl?: string;
attempt: number;
};
export type UploadFn = (file: File, onProgress: (pct: number) => void, signal: AbortSignal) => Promise<void>;
export interface FileDropzoneHandle {
addFiles: (files: File[]) => void;
clear: () => void;
}
export interface FileDropzoneProps {
/** MIME types / wildcards / extensions, e.g. ["image/*", "application/pdf", ".zip"]. */
accept?: string[];
/** Bytes. */
maxSize?: number;
maxFiles?: number;
multiple?: boolean;
/** Real uploader. Omit to use the built-in progress simulation. */
upload?: UploadFn;
/** Simulation only: return true to make an attempt fail. */
simulateFailure?: (file: File, attempt: number) => boolean;
onChange?: (items: UploadItem[]) => void;
title?: string;
hint?: string;
disabled?: boolean;
ref?: React.Ref<FileDropzoneHandle>;
className?: string;
}
/* ---------------------------------------------------------------------------- */
export function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
const u = ["KB", "MB", "GB"];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i++;
}
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
}
function matchesAccept(file: { name: string; type: string }, accept?: string[]) {
if (!accept?.length) return true;
const name = file.name.toLowerCase();
const type = file.type.toLowerCase();
return accept.some((a) => {
const rule = a.trim().toLowerCase();
if (rule.startsWith(".")) return name.endsWith(rule);
if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
return type === rule;
});
}
function describeAccept(accept?: string[]) {
if (!accept?.length) return "Any file";
return accept
.map((a) => (a.endsWith("/*") ? a.slice(0, -2).replace(/^\w/, (c) => c.toUpperCase()) + "s" : a.startsWith(".") ? a.slice(1).toUpperCase() : (a.split("/")[1] ?? a).toUpperCase()))
.join(", ");
}
const simulated =
(fail?: (f: File, attempt: number) => boolean) =>
(attempt: number): UploadFn =>
(file, onProgress, signal) =>
new Promise<void>((resolve, reject) => {
// Larger files take longer; capped so the demo stays snappy.
const total = Math.min(4200, 900 + file.size / 900);
const willFail = fail?.(file, attempt) ?? false;
const failAt = 0.35 + ((file.size % 37) / 37) * 0.4;
let elapsed = 0;
const tick = 60;
const id = window.setInterval(() => {
elapsed += tick * (0.6 + (Math.sin(elapsed / 180) + 1) * 0.5); // uneven, network-like
const pct = Math.min(1, elapsed / total);
if (willFail && pct >= failAt) {
window.clearInterval(id);
reject(new Error("Network error — connection reset"));
return;
}
onProgress(pct);
if (pct >= 1) {
window.clearInterval(id);
resolve();
}
}, tick);
signal.addEventListener("abort", () => {
window.clearInterval(id);
reject(new DOMException("Aborted", "AbortError"));
});
});
function kindIcon(file: File) {
const t = file.type;
if (t.startsWith("image/")) return { Icon: ImageIcon, tint: "from-violet-500 to-indigo-500" };
if (t.startsWith("video/")) return { Icon: Film, tint: "from-fuchsia-500 to-pink-500" };
if (t.startsWith("audio/")) return { Icon: Music, tint: "from-emerald-500 to-teal-500" };
if (t === "application/pdf" || t.startsWith("text/")) return { Icon: FileText, tint: "from-rose-500 to-orange-500" };
if (/zip|compressed|tar|rar/.test(t) || /\.(zip|rar|7z|tar|gz)$/i.test(file.name)) return { Icon: FileArchive, tint: "from-amber-500 to-yellow-500" };
return { Icon: FileIcon, tint: "from-sky-500 to-indigo-500" };
}
/* ---------------------------------------------------------------------------- */
export function FileDropzone({
accept = ["image/*", "application/pdf", ".zip"],
maxSize = 5 * 1024 * 1024,
maxFiles = 8,
multiple = true,
upload,
simulateFailure,
onChange,
title = "Drop files to upload",
hint,
disabled = false,
ref,
className,
}: FileDropzoneProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const [items, setItems] = React.useState<UploadItem[]>([]);
const [drag, setDrag] = React.useState<"idle" | "over" | "invalid">("idle");
const [announce, setAnnounce] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const depth = React.useRef(0);
const controllers = React.useRef(new Map<string, AbortController>());
const urls = React.useRef(new Set<string>());
const seq = React.useRef(0);
const countRef = React.useRef(0);
const update = React.useCallback(
(fn: (prev: UploadItem[]) => UploadItem[]) =>
setItems((prev) => {
const next = fn(prev);
countRef.current = next.filter((i) => i.status !== "rejected").length;
return next;
}),
[],
);
React.useEffect(() => {
onChange?.(items);
}, [items, onChange]);
// revoke previews + abort uploads on unmount
React.useEffect(() => {
const c = controllers.current;
const u = urls.current;
return () => {
c.forEach((x) => x.abort());
u.forEach((x) => URL.revokeObjectURL(x));
};
}, []);
const start = React.useCallback(
(id: string, file: File, attempt: number) => {
const ctrl = new AbortController();
controllers.current.set(id, ctrl);
const run = upload ?? simulated(simulateFailure)(attempt);
run(
file,
(pct) => update((prev) => prev.map((it) => (it.id === id ? { ...it, progress: Math.max(it.progress, pct) } : it))),
ctrl.signal,
)
.then(() => {
controllers.current.delete(id);
update((prev) => prev.map((it) => (it.id === id ? { ...it, status: "done", progress: 1 } : it)));
setAnnounce(`${file.name} uploaded`);
})
.catch((err: unknown) => {
controllers.current.delete(id);
if (ctrl.signal.aborted) return;
const msg = err instanceof Error ? err.message : "Upload failed";
update((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error", error: msg } : it)));
setAnnounce(`${file.name} failed: ${msg}`);
});
},
[upload, simulateFailure, update],
);
const addFiles = React.useCallback(
(files: File[]) => {
if (disabled) return;
const list = multiple ? files : files.slice(0, 1);
let room = maxFiles - countRef.current;
const fresh: UploadItem[] = list.map((file) => {
const id = `${uid}-${seq.current++}`;
let error: string | undefined;
if (!matchesAccept(file, accept)) error = "File type not allowed";
else if (file.size > maxSize) error = `Too large — max ${formatBytes(maxSize)}`;
else if (room <= 0) error = `Limit of ${maxFiles} files reached`;
else room--;
let previewUrl: string | undefined;
if (!error && file.type.startsWith("image/")) {
previewUrl = URL.createObjectURL(file);
urls.current.add(previewUrl);
}
return { id, file, status: error ? "rejected" : "uploading", progress: 0, error, previewUrl, attempt: 1 };
});
update((prev) => (multiple ? [...fresh, ...prev] : fresh));
fresh.filter((f) => f.status === "uploading").forEach((f) => start(f.id, f.file, 1));
const bad = fresh.filter((f) => f.status === "rejected").length;
setAnnounce(`${fresh.length - bad} file(s) added${bad ? `, ${bad} rejected` : ""}`);
},
[disabled, multiple, maxFiles, uid, accept, maxSize, update, start],
);
const remove = (id: string) => {
controllers.current.get(id)?.abort();
controllers.current.delete(id);
update((prev) => {
const it = prev.find((x) => x.id === id);
if (it?.previewUrl) {
URL.revokeObjectURL(it.previewUrl);
urls.current.delete(it.previewUrl);
}
return prev.filter((x) => x.id !== id);
});
};
const retry = (id: string) => {
const it = items.find((x) => x.id === id);
if (!it) return;
const attempt = it.attempt + 1;
update((prev) => prev.map((x) => (x.id === id ? { ...x, status: "uploading", progress: 0, error: undefined, attempt } : x)));
start(id, it.file, attempt);
};
React.useImperativeHandle(
ref,
() => ({
addFiles,
clear: () => {
controllers.current.forEach((c) => c.abort());
controllers.current.clear();
urls.current.forEach((u) => URL.revokeObjectURL(u));
urls.current.clear();
update(() => []);
},
}),
[addFiles, update],
);
/* drag handlers */
const onDragEnter = (e: React.DragEvent) => {
if (disabled || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
depth.current++;
const types = Array.from(e.dataTransfer.items).map((i) => ({ name: "", type: i.type }));
// extensions are unknown until drop; only flag MIME-typed mismatches
const invalid = types.some((t) => t.type && !accept.some((a) => a.startsWith(".")) && !matchesAccept(t, accept));
setDrag(invalid ? "invalid" : "over");
};
const onDragOver = (e: React.DragEvent) => {
if (disabled || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = drag === "invalid" ? "none" : "copy";
};
const onDragLeave = () => {
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDrag("idle");
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
depth.current = 0;
setDrag("idle");
if (e.dataTransfer.files.length) addFiles(Array.from(e.dataTransfer.files));
};
const accepted = items.filter((i) => i.status !== "rejected");
const doneCount = items.filter((i) => i.status === "done").length;
const uploading = items.filter((i) => i.status === "uploading");
const overall = uploading.length ? uploading.reduce((s, i) => s + i.progress, 0) / uploading.length : 0;
const hintText = hint ?? `${describeAccept(accept)} · up to ${formatBytes(maxSize)} each · max ${maxFiles} files`;
const over = drag !== "idle";
const compact = items.length > 0;
return (
<div className={cn("w-full max-w-xl", className)} onPaste={(e) => e.clipboardData.files.length && addFiles(Array.from(e.clipboardData.files))}>
<input
ref={inputRef}
id={`${uid}-input`}
type="file"
multiple={multiple}
accept={accept.join(",")}
className="sr-only"
tabIndex={-1}
aria-hidden
onChange={(e) => {
if (e.target.files) addFiles(Array.from(e.target.files));
e.target.value = "";
}}
/>
<motion.button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
layout={!reduce}
animate={over && !reduce ? { scale: 1.015 } : { scale: 1 }}
transition={{ type: "spring", stiffness: 400, damping: 32 }}
aria-describedby={`${uid}-hint`}
className={cn(
"group relative flex w-full items-center justify-center overflow-hidden rounded-2xl outline-none transition-colors",
compact ? "flex-row gap-4 px-5 py-4 text-left" : "flex-col gap-3 px-6 py-9 text-center",
"focus-visible:ring-4 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50",
drag === "over" ? "bg-primary/[0.06]" : drag === "invalid" ? "bg-destructive/[0.06]" : "bg-muted/30 hover:bg-muted/60",
)}
>
{/* animated dashed border */}
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full overflow-visible">
<motion.rect
x="1"
y="1"
rx="15"
style={{ width: "calc(100% - 2px)", height: "calc(100% - 2px)" }}
fill="none"
strokeWidth={over ? 2 : 1.5}
strokeDasharray="7 7"
className={cn(
"transition-colors",
drag === "over" ? "stroke-primary" : drag === "invalid" ? "stroke-destructive" : "stroke-foreground/20 group-hover:stroke-foreground/35",
)}
animate={over && !reduce ? { strokeDashoffset: [0, -28] } : { strokeDashoffset: 0 }}
transition={over ? { duration: 0.8, ease: "linear", repeat: Infinity } : { duration: 0 }}
/>
</svg>
{/* glow */}
<motion.span
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 size-64 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20 blur-3xl"
animate={{ opacity: drag === "over" ? 1 : 0, scale: drag === "over" ? 1 : 0.6 }}
/>
<motion.span
layout={!reduce}
animate={over && !reduce ? { y: -6, scale: 1.08 } : { y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 380, damping: 18 }}
className={cn(
"relative grid shrink-0 place-items-center rounded-2xl border bg-background shadow-sm",
compact ? "size-11" : "size-14",
drag === "invalid" ? "text-destructive" : "text-primary",
)}
>
{drag === "invalid" ? <AlertCircle className="size-6" /> : <UploadCloud className="size-6" />}
{!reduce && drag === "idle" && (
<motion.span
aria-hidden
className="absolute inset-0 rounded-2xl border border-primary/40"
animate={{ scale: [1, 1.35], opacity: [0.6, 0] }}
transition={{ duration: 2.2, repeat: Infinity, ease: "easeOut" }}
/>
)}
</motion.span>
<motion.span layout={!reduce ? "position" : false} className={cn("relative min-w-0", compact ? "flex-1" : "space-y-1")}>
<span className="block text-sm font-semibold">
{drag === "over" ? "Release to upload" : drag === "invalid" ? "Some files aren’t supported" : title}
</span>
<span className="block text-sm text-muted-foreground">
or <span className="font-medium text-primary underline-offset-4 group-hover:underline">browse your device</span>
</span>
{compact && (
<span id={`${uid}-hint`} className="mt-0.5 block truncate text-xs text-muted-foreground/80">
{hintText}
</span>
)}
</motion.span>
{!compact && (
<span id={`${uid}-hint`} className="relative text-xs text-muted-foreground/80">
{hintText}
</span>
)}
</motion.button>
{items.length > 0 && (
<div className="mt-3">
<div className="mb-2 flex items-center justify-between px-1 text-xs text-muted-foreground">
<span>
{accepted.length} {accepted.length === 1 ? "file" : "files"} · {doneCount} uploaded
</span>
{uploading.length > 0 && <span className="tabular-nums">{Math.round(overall * 100)}% · {uploading.length} in progress</span>}
</div>
<ul className="flex flex-col gap-2" aria-label="Files">
<AnimatePresence initial={false}>
{items.map((it) => (
<FileRow key={it.id} item={it} reduce={!!reduce} onRemove={() => remove(it.id)} onRetry={() => retry(it.id)} />
))}
</AnimatePresence>
</ul>
</div>
)}
<span className="sr-only" aria-live="polite">
{announce}
</span>
</div>
);
}
function FileRow({ item, reduce, onRemove, onRetry }: { item: UploadItem; reduce: boolean; onRemove: () => void; onRetry: () => void }) {
const { file, status, progress, previewUrl, error } = item;
const { Icon, tint } = kindIcon(file);
const bad = status === "error" || status === "rejected";
return (
<motion.li
layout={!reduce}
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: reduce ? 0 : 24, transition: { duration: 0.18 } }}
transition={{ type: "spring", stiffness: 480, damping: 36 }}
className={cn(
"relative flex items-center gap-3 overflow-hidden rounded-xl border bg-card p-2 pr-1.5 text-card-foreground shadow-xs",
bad && "border-destructive/30 bg-destructive/[0.04]",
)}
>
<span className="relative size-10 shrink-0 overflow-hidden rounded-lg">
{previewUrl ? (
<img src={previewUrl} alt="" className="size-full object-cover" />
) : (
<span className={cn("grid size-full place-items-center bg-gradient-to-br text-white", tint)}>
<Icon className="size-5" aria-hidden />
</span>
)}
<AnimatePresence>
{status === "done" && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 600, damping: 20 }}
className="absolute bottom-0.5 right-0.5 grid size-4 place-items-center rounded-full bg-emerald-500 text-white ring-2 ring-card"
>
<Check className="size-2.5" strokeWidth={3.5} aria-hidden />
</motion.span>
)}
</AnimatePresence>
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium" title={file.name}>
{file.name}
</p>
<p className={cn("mt-0.5 truncate text-xs", bad ? "text-destructive" : "text-muted-foreground")}>
{formatBytes(file.size)}
<span className="mx-1 opacity-50">·</span>
{status === "uploading" ? `Uploading ${Math.round(progress * 100)}%` : status === "done" ? "Uploaded" : error}
</p>
{(status === "uploading" || status === "error") && (
<div
role="progressbar"
aria-label={`${file.name} upload progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted"
>
<motion.div
className={cn("h-full rounded-full", status === "error" ? "bg-destructive" : "bg-gradient-to-r from-primary/70 to-primary")}
initial={false}
animate={{ width: `${Math.max(3, progress * 100)}%` }}
transition={{ type: "spring", stiffness: 140, damping: 24 }}
/>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5">
{status === "error" && (
<button
type="button"
onClick={onRetry}
aria-label={`Retry ${file.name}`}
className="grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
>
<RotateCcw className="size-4" />
</button>
)}
<button
type="button"
onClick={onRemove}
aria-label={status === "uploading" ? `Cancel ${file.name}` : `Remove ${file.name}`}
className="grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
>
<X className="size-4" />
</button>
</div>
</motion.li>
);
}