Web InnoventixFreeCode

File Manager Upload

Original · free

upload dropzone with progress

byWeb InnoventixReact + Tailwind
filemgruploadfilemanager
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/filemgr-upload.json
filemgr-upload.tsx
"use client";

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type DragEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Kind = "image" | "pdf" | "sheet" | "video" | "archive" | "doc";
type Status = "uploading" | "conflict" | "done" | "error";
type Resolution = "replaced" | "renamed";

interface QueueItem {
  id: string;
  name: string;
  size: number;
  kind: Kind;
  progress: number;
  status: Status;
  note: string | null;
  retryable: boolean;
  resolution: Resolution | null;
}

interface Folder {
  id: string;
  label: string;
  path: string;
  existing: string[];
}

const FOLDERS: Folder[] = [
  {
    id: "q3-launch",
    label: "Q3 Launch",
    path: "Workspace / Marketing / Q3 Launch",
    existing: ["launch-deck.pdf", "hero-poster.webp", "pricing-v4.xlsx"],
  },
  {
    id: "northwind",
    label: "Client · Northwind",
    path: "Workspace / Clients / Northwind",
    existing: ["logo-primary.svg", "brand-guide.pdf"],
  },
  {
    id: "archive-2025",
    label: "Archive 2025",
    path: "Workspace / Archive / 2025",
    existing: ["q4-report.pdf"],
  },
];

const MAX_BYTES = 250_000_000;
const QUOTA_BYTES = 50_000_000_000;
const BASE_USED_BYTES = 19_756_000_000;

const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "webp", "gif", "svg", "avif", "tiff", "heic"]);
const SHEET_EXT = new Set(["xlsx", "xls", "csv", "numbers"]);
const VIDEO_EXT = new Set(["mp4", "mov", "webm", "avi", "mkv"]);
const ARCHIVE_EXT = new Set(["zip", "rar", "7z", "tar", "gz"]);

const SEED: QueueItem[] = [
  {
    id: "fmu-seed-1",
    name: "northwind-hero-cut.mp4",
    size: 148_900_000,
    kind: "video",
    progress: 46,
    status: "uploading",
    note: null,
    retryable: true,
    resolution: null,
  },
  {
    id: "fmu-seed-2",
    name: "launch-deck.pdf",
    size: 8_400_000,
    kind: "pdf",
    progress: 0,
    status: "conflict",
    note: "A file with this name is already in Q3 Launch",
    retryable: true,
    resolution: null,
  },
  {
    id: "fmu-seed-3",
    name: "field-photos-batch.zip",
    size: 62_300_000,
    kind: "archive",
    progress: 100,
    status: "done",
    note: null,
    retryable: false,
    resolution: null,
  },
  {
    id: "fmu-seed-4",
    name: "pricing-v4.xlsx",
    size: 412_000,
    kind: "sheet",
    progress: 71,
    status: "error",
    note: "Transfer interrupted — the connection reset",
    retryable: true,
    resolution: null,
  },
  {
    id: "fmu-seed-5",
    name: "print-master-cmyk.tiff",
    size: 268_400_000,
    kind: "image",
    progress: 0,
    status: "error",
    note: "Over the 250 MB per-file limit",
    retryable: false,
    resolution: null,
  },
];

function extOf(name: string): string {
  const dot = name.lastIndexOf(".");
  return dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
}

function kindOf(name: string): Kind {
  const ext = extOf(name);
  if (IMAGE_EXT.has(ext)) return "image";
  if (ext === "pdf") return "pdf";
  if (SHEET_EXT.has(ext)) return "sheet";
  if (VIDEO_EXT.has(ext)) return "video";
  if (ARCHIVE_EXT.has(ext)) return "archive";
  return "doc";
}

function formatSize(bytes: number): string {
  if (bytes === 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1000)));
  const val = bytes / Math.pow(1000, i);
  return `${val >= 100 || i === 0 ? Math.round(val) : val.toFixed(1)} ${units[i]}`;
}

function uniqueName(name: string, taken: Set<string>): string {
  const dot = name.lastIndexOf(".");
  const stem = dot === -1 ? name : name.slice(0, dot);
  const tail = dot === -1 ? "" : name.slice(dot);
  let n = 1;
  let next = `${stem} (${n})${tail}`;
  while (taken.has(next.toLowerCase())) {
    n += 1;
    next = `${stem} (${n})${tail}`;
  }
  return next;
}

const KIND_LABEL: Record<Kind, string> = {
  image: "Image",
  pdf: "PDF",
  sheet: "Spreadsheet",
  video: "Video",
  archive: "Archive",
  doc: "Document",
};

const KIND_TILE: Record<Kind, string> = {
  image: "bg-sky-50 text-sky-600 dark:bg-sky-500/10 dark:text-sky-300",
  pdf: "bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-300",
  sheet: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-300",
  video: "bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300",
  archive: "bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-300",
  doc: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
};

function FolderIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.1a1.5 1.5 0 0 1 1.2.6l1 1.4h8.7A1.5 1.5 0 0 1 21 9.5v8A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z" />
    </svg>
  );
}

function TrayIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 16V4" />
      <path d="m7.5 8.5 4.5-4.5 4.5 4.5" />
      <path d="M4 14v3.5A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V14" />
    </svg>
  );
}

function ImageGlyph({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="4.5" width="18" height="15" rx="2.5" />
      <circle cx="8.5" cy="10" r="1.5" />
      <path d="m3.5 16.5 4.6-4.2a2 2 0 0 1 2.7 0l6.4 5.9" />
    </svg>
  );
}

function PageGlyph({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M13 3H7.5A2 2 0 0 0 5.5 5v14a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V8.5z" />
      <path d="M13 3v5.5h5.5" />
      <path d="M9 13h6M9 16.5h4" />
    </svg>
  );
}

function SheetGlyph({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="4" y="4" width="16" height="16" rx="2.5" />
      <path d="M4 9.5h16M4 14.5h16M10.5 9.5V20" />
    </svg>
  );
}

function VideoGlyph({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="5.5" width="13" height="13" rx="2.5" />
      <path d="m16 11 5-2.8v7.6L16 13z" />
    </svg>
  );
}

function ArchiveGlyph({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3.5" y="4" width="17" height="4.5" rx="1.5" />
      <path d="M5 8.5v9.5A2 2 0 0 0 7 20h10a2 2 0 0 0 2-2V8.5" />
      <path d="M11 12h2M11 15h2" />
    </svg>
  );
}

function KindGlyph({ kind, className }: { kind: Kind; className?: string }) {
  if (kind === "image") return <ImageGlyph className={className} />;
  if (kind === "sheet") return <SheetGlyph className={className} />;
  if (kind === "video") return <VideoGlyph className={className} />;
  if (kind === "archive") return <ArchiveGlyph className={className} />;
  return <PageGlyph className={className} />;
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.4}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m5 12.5 4.5 4.5L19 7" />
    </svg>
  );
}

function AlertIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 8.5v4.5M12 16.5h.01" />
      <circle cx="12" cy="12" r="8.75" />
    </svg>
  );
}

function RetryIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.9}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M20 5.5v5h-5" />
      <path d="M19.4 14a7.5 7.5 0 1 1-1.3-6.9L20 10.5" />
    </svg>
  );
}

function CloseIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M6.5 6.5l11 11M17.5 6.5l-11 11" />
    </svg>
  );
}

export default function FileManagerUpload() {
  const reduce = useReducedMotion();
  const inputRef = useRef<HTMLInputElement>(null);
  const timers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
  const dragDepth = useRef(0);
  const seq = useRef(0);

  const [items, setItems] = useState<QueueItem[]>(SEED);
  const [destId, setDestId] = useState<string>(FOLDERS[0].id);
  const [isDragging, setIsDragging] = useState(false);
  const [announce, setAnnounce] = useState("");

  const dest = FOLDERS.find((f) => f.id === destId) ?? FOLDERS[0];

  const startTransfer = useCallback((id: string) => {
    const existing = timers.current.get(id);
    if (existing !== undefined) clearInterval(existing);
    const timer = setInterval(() => {
      setItems((prev) =>
        prev.map((item) => {
          if (item.id !== id || item.status !== "uploading") return item;
          const next = Math.min(100, item.progress + Math.random() * 9 + 3);
          if (next >= 100) {
            const t = timers.current.get(id);
            if (t !== undefined) {
              clearInterval(t);
              timers.current.delete(id);
            }
            return { ...item, progress: 100, status: "done", note: item.note };
          }
          return { ...item, progress: next };
        }),
      );
    }, 420);
    timers.current.set(id, timer);
  }, []);

  useEffect(() => {
    SEED.forEach((item) => {
      if (item.status === "uploading") startTransfer(item.id);
    });
    const active = timers.current;
    return () => {
      active.forEach((t) => clearInterval(t));
      active.clear();
    };
  }, [startTransfer]);

  const stopTransfer = useCallback((id: string) => {
    const t = timers.current.get(id);
    if (t !== undefined) {
      clearInterval(t);
      timers.current.delete(id);
    }
  }, []);

  const addFiles = useCallback(
    (list: FileList) => {
      const incoming = Array.from(list);
      if (incoming.length === 0) return;

      const taken = new Set<string>([
        ...dest.existing.map((n) => n.toLowerCase()),
        ...items.map((i) => i.name.toLowerCase()),
      ]);

      const created: QueueItem[] = incoming.map((file) => {
        const id = `fmu-${Date.now().toString(36)}-${seq.current++}`;
        const clash = taken.has(file.name.toLowerCase());
        taken.add(file.name.toLowerCase());

        if (file.size > MAX_BYTES) {
          return {
            id,
            name: file.name,
            size: file.size,
            kind: kindOf(file.name),
            progress: 0,
            status: "error",
            note: "Over the 250 MB per-file limit",
            retryable: false,
            resolution: null,
          };
        }

        return {
          id,
          name: file.name,
          size: file.size,
          kind: kindOf(file.name),
          progress: 0,
          status: clash ? "conflict" : "uploading",
          note: clash ? `A file with this name is already in ${dest.label}` : null,
          retryable: true,
          resolution: null,
        };
      });

      setItems((prev) => [...created, ...prev]);

      created.forEach((item) => {
        if (item.status === "uploading") startTransfer(item.id);
      });

      const clashes = created.filter((i) => i.status === "conflict").length;
      setAnnounce(
        `${created.length} file${created.length === 1 ? "" : "s"} queued for ${dest.label}` +
          (clashes > 0 ? `. ${clashes} need a name conflict resolved.` : ""),
      );
    },
    [dest, items, startTransfer],
  );

  const resolveConflict = useCallback(
    (id: string, choice: Resolution) => {
      const target = items.find((i) => i.id === id);
      if (!target) return;

      const taken = new Set<string>([
        ...dest.existing.map((n) => n.toLowerCase()),
        ...items.filter((i) => i.id !== id).map((i) => i.name.toLowerCase()),
      ]);
      const name = choice === "renamed" ? uniqueName(target.name, taken) : target.name;

      setItems((prev) =>
        prev.map((item) =>
          item.id === id
            ? {
                ...item,
                name,
                status: "uploading",
                progress: 0,
                resolution: choice,
                note:
                  choice === "renamed"
                    ? "Saved alongside the original"
                    : "Overwrites the existing copy",
              }
            : item,
        ),
      );
      startTransfer(id);
      setAnnounce(
        choice === "renamed"
          ? `Keeping both. Uploading as ${name}.`
          : `Replacing the existing ${target.name} in ${dest.label}.`,
      );
    },
    [dest, items, startTransfer],
  );

  const retry = useCallback(
    (id: string) => {
      setItems((prev) =>
        prev.map((item) =>
          item.id === id
            ? { ...item, status: "uploading", progress: 0, note: "Retrying transfer" }
            : item,
        ),
      );
      startTransfer(id);
      setAnnounce("Retrying transfer");
    },
    [startTransfer],
  );

  const remove = useCallback(
    (item: QueueItem) => {
      stopTransfer(item.id);
      setItems((prev) => prev.filter((i) => i.id !== item.id));
      setAnnounce(`${item.name} removed from the queue`);
    },
    [stopTransfer],
  );

  const clearFinished = useCallback(() => {
    setItems((prev) => prev.filter((i) => i.status !== "done"));
    setAnnounce("Finished uploads cleared from the list");
  }, []);

  const onDestChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    const folder = FOLDERS.find((f) => f.id === e.target.value);
    if (!folder) return;
    setDestId(folder.id);
    setAnnounce(`Destination set to ${folder.path}`);
  }, []);

  const onInputChange = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      if (e.target.files) addFiles(e.target.files);
      e.target.value = "";
    },
    [addFiles],
  );

  const onDragEnter = useCallback((e: DragEvent<HTMLButtonElement>) => {
    e.preventDefault();
    dragDepth.current += 1;
    setIsDragging(true);
  }, []);

  const onDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
    e.preventDefault();
  }, []);

  const onDragLeave = useCallback((e: DragEvent<HTMLButtonElement>) => {
    e.preventDefault();
    dragDepth.current -= 1;
    if (dragDepth.current <= 0) {
      dragDepth.current = 0;
      setIsDragging(false);
    }
  }, []);

  const onDrop = useCallback(
    (e: DragEvent<HTMLButtonElement>) => {
      e.preventDefault();
      dragDepth.current = 0;
      setIsDragging(false);
      if (e.dataTransfer.files) addFiles(e.dataTransfer.files);
    },
    [addFiles],
  );

  const transferable = items.filter((i) => i.status !== "error");
  const totalBytes = transferable.reduce((sum, i) => sum + i.size, 0);
  const movedBytes = transferable.reduce((sum, i) => sum + (i.size * i.progress) / 100, 0);
  const overall = totalBytes === 0 ? 0 : Math.round((movedBytes / totalBytes) * 100);

  const inFlight = items.filter((i) => i.status === "uploading").length;
  const conflicts = items.filter((i) => i.status === "conflict").length;
  const finished = items.filter((i) => i.status === "done").length;

  const usedBytes =
    BASE_USED_BYTES + items.filter((i) => i.status === "done").reduce((s, i) => s + i.size, 0);
  const usedPct = Math.min(100, (usedBytes / QUOTA_BYTES) * 100);

  const RING_R = 20;
  const CIRC = 2 * Math.PI * RING_R;

  const rowMotion = reduce
    ? {}
    : {
        initial: { opacity: 0, y: 8 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, x: -12, height: 0, marginBottom: 0 },
        transition: { duration: 0.2 },
      };

  return (
    <section className="relative w-full bg-slate-100 px-4 py-16 sm:px-6 lg:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes fmuStripe { from { background-position: 0 0; } to { background-position: 28px 0; } }
        @keyframes fmuPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.72); } }
        @keyframes fmuLift { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
        .fmu-stripe {
          background-image: repeating-linear-gradient(115deg, rgba(255,255,255,.30) 0 7px, transparent 7px 14px);
          background-size: 28px 100%;
          animation: fmuStripe .7s linear infinite;
        }
        .fmu-pulse { animation: fmuPulse 1.6s ease-in-out infinite; }
        .fmu-lift { animation: fmuLift 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .fmu-stripe, .fmu-pulse, .fmu-lift { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-6">
          <h2 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Move files into your workspace
          </h2>
          <p className="mt-2 max-w-xl text-sm text-slate-600 dark:text-slate-400">
            Pick a destination folder, then drop files in. Anything that collides with a
            filename already in that folder waits for you to decide.
          </p>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex flex-col gap-5 border-b border-slate-200 p-5 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
            <div className="min-w-0">
              <div className="flex items-center gap-2 text-slate-400 dark:text-slate-500">
                <FolderIcon className="h-4 w-4 shrink-0" />
                <p className="truncate text-xs font-medium tracking-wide">{dest.path}</p>
              </div>
              <p className="mt-1.5 text-lg font-semibold text-slate-900 dark:text-white">
                {inFlight > 0
                  ? `Uploading ${inFlight} file${inFlight === 1 ? "" : "s"}`
                  : conflicts > 0
                    ? `${conflicts} name conflict${conflicts === 1 ? "" : "s"} to resolve`
                    : "Transfer queue idle"}
              </p>
              <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                {formatSize(movedBytes)} of {formatSize(totalBytes)} moved · {finished} complete
              </p>
            </div>

            <div className="flex shrink-0 items-center gap-4">
              <div className="relative h-14 w-14">
                <svg viewBox="0 0 48 48" className="h-14 w-14 -rotate-90" aria-hidden="true">
                  <circle
                    cx="24"
                    cy="24"
                    r={RING_R}
                    fill="none"
                    strokeWidth="4"
                    className="stroke-slate-200 dark:stroke-slate-800"
                  />
                  <circle
                    cx="24"
                    cy="24"
                    r={RING_R}
                    fill="none"
                    strokeWidth="4"
                    strokeLinecap="round"
                    strokeDasharray={CIRC}
                    strokeDashoffset={CIRC - (CIRC * overall) / 100}
                    className="stroke-violet-500 transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none dark:stroke-violet-400"
                  />
                </svg>
                <span className="absolute inset-0 flex items-center justify-center text-xs font-bold tabular-nums text-slate-700 dark:text-slate-200">
                  {overall}%
                </span>
              </div>
              <p className="sr-only" aria-live="polite">
                Overall transfer progress {overall} percent
              </p>
              <button
                type="button"
                onClick={clearFinished}
                disabled={finished === 0}
                className="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
              >
                Clear finished
              </button>
            </div>
          </div>

          <div className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
            <fieldset>
              <legend className="mb-2.5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Destination folder
              </legend>
              <div className="flex flex-wrap gap-2">
                {FOLDERS.map((folder) => (
                  <label key={folder.id} className="cursor-pointer">
                    <input
                      type="radio"
                      name="fmu-destination"
                      value={folder.id}
                      checked={destId === folder.id}
                      onChange={onDestChange}
                      className="peer sr-only"
                    />
                    <span className="flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors peer-hover:border-slate-300 peer-hover:bg-slate-50 peer-checked:border-violet-500 peer-checked:bg-violet-50 peer-checked:text-violet-700 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:peer-hover:border-slate-600 dark:peer-hover:bg-slate-800 dark:peer-checked:border-violet-400 dark:peer-checked:bg-violet-500/15 dark:peer-checked:text-violet-200 dark:peer-focus-visible:ring-offset-slate-900">
                      <FolderIcon className="h-4 w-4" />
                      {folder.label}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>
          </div>

          <div className="p-5">
            <input
              ref={inputRef}
              type="file"
              multiple
              onChange={onInputChange}
              className="sr-only"
              tabIndex={-1}
              aria-hidden="true"
            />

            <button
              type="button"
              onClick={() => inputRef.current?.click()}
              onDragEnter={onDragEnter}
              onDragOver={onDragOver}
              onDragLeave={onDragLeave}
              onDrop={onDrop}
              aria-label={`Add files to ${dest.path}. Activate to browse, or drag files onto this area.`}
              className={[
                "group flex w-full items-center gap-4 rounded-xl border-2 border-dashed px-5 py-6 text-left transition-colors duration-200",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                isDragging
                  ? "border-violet-500 bg-violet-50 dark:border-violet-400 dark:bg-violet-500/10"
                  : "border-slate-300 hover:border-violet-400 hover:bg-violet-50/50 dark:border-slate-700 dark:hover:border-violet-500 dark:hover:bg-slate-800/60",
              ].join(" ")}
            >
              <span
                className={[
                  "flex h-11 w-11 shrink-0 items-center justify-center rounded-lg transition-colors",
                  isDragging
                    ? "bg-violet-100 text-violet-600 dark:bg-violet-500/20 dark:text-violet-300"
                    : "bg-slate-100 text-slate-500 group-hover:bg-violet-100 group-hover:text-violet-600 dark:bg-slate-800 dark:text-slate-400 dark:group-hover:bg-violet-500/20 dark:group-hover:text-violet-300",
                ].join(" ")}
              >
                <TrayIcon className={`h-5 w-5 ${isDragging && !reduce ? "fmu-lift" : ""}`} />
              </span>
              <span className="min-w-0">
                <span className="block text-sm font-semibold text-slate-800 dark:text-slate-100">
                  {isDragging ? `Release to drop into ${dest.label}` : "Drop files here, or browse"}
                </span>
                <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
                  Any file type · 250 MB per file · {formatSize(QUOTA_BYTES - usedBytes)} left in
                  your plan
                </span>
              </span>
            </button>

            <p aria-live="polite" className="sr-only">
              {announce}
            </p>

            <ul className="mt-4 space-y-2.5">
              <AnimatePresence initial={false}>
                {items.map((item) => {
                  const isConflict = item.status === "conflict";
                  const isError = item.status === "error";
                  const isDone = item.status === "done";
                  const pct = Math.round(item.progress);

                  return (
                    <motion.li
                      key={item.id}
                      layout={!reduce}
                      {...rowMotion}
                      className={[
                        "overflow-hidden rounded-xl border p-3",
                        isConflict
                          ? "border-amber-300 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/5"
                          : isError
                            ? "border-rose-200 bg-rose-50/50 dark:border-rose-500/40 dark:bg-rose-500/5"
                            : "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900",
                      ].join(" ")}
                    >
                      <div className="flex items-start gap-3">
                        <span
                          className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${KIND_TILE[item.kind]}`}
                        >
                          <KindGlyph kind={item.kind} className="h-5 w-5" />
                        </span>

                        <div className="min-w-0 flex-1">
                          <div className="flex items-baseline justify-between gap-3">
                            <p className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
                              {item.name}
                            </p>
                            <span className="shrink-0 text-xs tabular-nums text-slate-400 dark:text-slate-500">
                              {formatSize(item.size)}
                            </span>
                          </div>

                          <p className="mt-0.5 text-xs text-slate-400 dark:text-slate-500">
                            {KIND_LABEL[item.kind]}
                            {item.resolution === "renamed" ? " · kept both" : null}
                            {item.resolution === "replaced" ? " · replacing original" : null}
                          </p>

                          {isConflict ? (
                            <div className="mt-2.5 flex flex-wrap items-center gap-2">
                              <span className="flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-300">
                                <span className="fmu-pulse h-1.5 w-1.5 rounded-full bg-amber-500" />
                                {item.note}
                              </span>
                              <span className="flex gap-2">
                                <button
                                  type="button"
                                  onClick={() => resolveConflict(item.id, "renamed")}
                                  aria-label={`Keep both copies of ${item.name}`}
                                  className="rounded-md bg-amber-500 px-2.5 py-1 text-xs font-semibold text-white transition-colors hover:bg-amber-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                                >
                                  Keep both
                                </button>
                                <button
                                  type="button"
                                  onClick={() => resolveConflict(item.id, "replaced")}
                                  aria-label={`Replace the existing ${item.name} in ${dest.label}`}
                                  className="rounded-md border border-amber-300 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-amber-500/50 dark:text-amber-300 dark:hover:bg-amber-500/15 dark:focus-visible:ring-offset-slate-900"
                                >
                                  Replace
                                </button>
                              </span>
                            </div>
                          ) : isError ? (
                            <p className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
                              <AlertIcon className="h-3.5 w-3.5 shrink-0" />
                              {item.note}
                            </p>
                          ) : (
                            <div className="mt-2">
                              <div
                                role="progressbar"
                                aria-valuenow={pct}
                                aria-valuemin={0}
                                aria-valuemax={100}
                                aria-valuetext={`${pct} percent, ${formatSize((item.size * item.progress) / 100)} of ${formatSize(item.size)}`}
                                aria-label={`Upload progress for ${item.name}`}
                                className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"
                              >
                                <div
                                  className={[
                                    "relative h-full rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none",
                                    isDone ? "bg-emerald-500" : "bg-violet-500",
                                  ].join(" ")}
                                  style={{ width: `${item.progress}%` }}
                                >
                                  {!isDone ? (
                                    <span className="fmu-stripe absolute inset-0 rounded-full" />
                                  ) : null}
                                </div>
                              </div>
                              <p className="mt-1 text-xs tabular-nums text-slate-500 dark:text-slate-400">
                                {isDone
                                  ? item.resolution === "replaced"
                                    ? `Replaced in ${dest.label}`
                                    : `Saved to ${dest.label}`
                                  : `${formatSize((item.size * item.progress) / 100)} of ${formatSize(item.size)} · ${pct}%`}
                              </p>
                            </div>
                          )}
                        </div>

                        <div className="flex shrink-0 items-center gap-1">
                          {isDone ? (
                            <span
                              className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400"
                              title="Upload complete"
                            >
                              <CheckIcon className="h-3.5 w-3.5" />
                            </span>
                          ) : null}
                          {isError && item.retryable ? (
                            <button
                              type="button"
                              onClick={() => retry(item.id)}
                              aria-label={`Retry uploading ${item.name}`}
                              className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-violet-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-slate-800 dark:hover:text-violet-300 dark:focus-visible:ring-offset-slate-900"
                            >
                              <RetryIcon className="h-4 w-4" />
                            </button>
                          ) : null}
                          <button
                            type="button"
                            onClick={() => remove(item)}
                            aria-label={
                              item.status === "uploading"
                                ? `Cancel upload of ${item.name}`
                                : `Remove ${item.name} from the queue`
                            }
                            className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-rose-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-slate-800 dark:hover:text-rose-400 dark:focus-visible:ring-offset-slate-900"
                          >
                            <CloseIcon className="h-4 w-4" />
                          </button>
                        </div>
                      </div>
                    </motion.li>
                  );
                })}
              </AnimatePresence>
            </ul>

            {items.length === 0 ? (
              <p className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
                The transfer queue is empty. Drop files above to start.
              </p>
            ) : null}
          </div>

          <div className="border-t border-slate-200 px-5 py-4 dark:border-slate-800">
            <div className="flex items-baseline justify-between gap-3">
              <p className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Workspace storage
              </p>
              <p className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                {formatSize(usedBytes)} of {formatSize(QUOTA_BYTES)}
              </p>
            </div>
            <div
              role="progressbar"
              aria-valuenow={Math.round(usedPct)}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuetext={`${formatSize(usedBytes)} of ${formatSize(QUOTA_BYTES)} used`}
              aria-label="Workspace storage used"
              className="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"
            >
              <div
                className="h-full rounded-full bg-sky-500 transition-[width] duration-700 ease-out motion-reduce:transition-none dark:bg-sky-400"
                style={{ width: `${usedPct}%` }}
              />
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →