{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upload-tray",
  "type": "registry:component",
  "title": "Upload Tray",
  "description": "Drop files in and each one fans out into its own card with a coloured type badge, live progress and time left, while a failed upload waits patiently for a retry.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/upload-tray.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, CloudUploadIcon, RotateCwIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type DragEvent, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface UploadTrayFile {\n  name: string;\n  size: number;\n}\n\ntype Status = \"uploading\" | \"done\" | \"error\";\n\ninterface Upload extends UploadTrayFile {\n  id: string;\n  progress: number;\n  bytesPerSecond: number;\n  status: Status;\n  attempt: number;\n  tilt: number;\n  failAt?: number;\n}\n\nexport interface UploadTrayProps {\n  initialFiles?: UploadTrayFile[];\n  shouldFail?: (file: UploadTrayFile, attempt: number) => boolean;\n  onFiles?: (files: File[]) => void;\n  accept?: string;\n  hint?: string;\n  className?: string;\n}\n\nconst KINDS: { match: RegExp; label: string; className: string }[] = [\n  { match: /\\.pdf$/i, label: \"PDF\", className: \"bg-red-600 text-white\" },\n  { match: /\\.(png|jpe?g|gif|webp|svg|avif)$/i, label: \"IMG\", className: \"bg-blue-600 text-white\" },\n  { match: /\\.(mp4|mov|webm)$/i, label: \"VID\", className: \"bg-foreground text-background\" },\n  { match: /\\.(zip|tar|gz|rar)$/i, label: \"ZIP\", className: \"bg-amber-700 text-white\" },\n  { match: /\\.(docx?|txt|md|pages)$/i, label: \"DOC\", className: \"bg-sky-700 text-white\" },\n  { match: /\\.(csv|xlsx?|numbers)$/i, label: \"XLS\", className: \"bg-emerald-700 text-white\" },\n  { match: /\\.(fig|sketch|psd)$/i, label: \"FIG\", className: \"bg-violet-600 text-white\" },\n];\n\nfunction kindOf(name: string) {\n  const known = KINDS.find((k) => k.match.test(name));\n  if (known) return known;\n  const ext = name.split(\".\").pop()?.slice(0, 3).toUpperCase() ?? \"FILE\";\n  return { label: ext, className: \"bg-muted-foreground text-background\" };\n}\n\nfunction formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;\n  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;\n  return `${(bytes / 1024 ** 3).toFixed(2)} GB`;\n}\n\nfunction formatEta(seconds: number) {\n  if (!Number.isFinite(seconds)) return \"…\";\n  const s = Math.max(1, Math.ceil(seconds));\n  if (s < 60) return `${s}s left`;\n  return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, \"0\")}s left`;\n}\n\nlet uid = 0;\nconst makeUpload = (file: UploadTrayFile): Upload => ({\n  ...file,\n  id: `u${++uid}`,\n  progress: 0,\n  bytesPerSecond: (0.35 + Math.random() * 0.9) * 1024 ** 2,\n  status: \"uploading\",\n  attempt: 0,\n  tilt: (Math.random() - 0.5) * 14,\n});\n\nconst TICK = 120;\n\nexport function UploadTray({\n  initialFiles = [],\n  shouldFail = () => false,\n  onFiles,\n  accept,\n  hint = \"PDF, images, video or archives · up to 50 MB\",\n  className,\n}: UploadTrayProps) {\n  const [uploads, setUploads] = useState<Upload[]>([]);\n  const [dragging, setDragging] = useState(false);\n  const depth = useRef(0);\n  const reduce = useReducedMotion();\n  const inputId = useId();\n  const shouldFailRef = useRef(shouldFail);\n  useEffect(() => {\n    shouldFailRef.current = shouldFail;\n  }, [shouldFail]);\n\n  const add = (files: UploadTrayFile[]) => {\n    const next = files.map((file) => {\n      const u = makeUpload(file);\n      return shouldFailRef.current(u, 0) ? { ...u, failAt: 0.35 + Math.random() * 0.3 } : u;\n    });\n    setUploads((list) => [...next, ...list]);\n  };\n\n  const seeded = useRef(false);\n  useEffect(() => {\n    if (seeded.current || initialFiles.length === 0) return;\n    seeded.current = true;\n    const timers = initialFiles.map((file, i) => window.setTimeout(() => add([file]), 250 + i * 260));\n    return () => {\n      timers.forEach(clearTimeout);\n      seeded.current = false;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const active = uploads.some((u) => u.status === \"uploading\");\n  useEffect(() => {\n    if (!active) return;\n    const id = window.setInterval(() => {\n      setUploads((list) =>\n        list.map((u) => {\n          if (u.status !== \"uploading\") return u;\n          const jitter = 0.7 + Math.random() * 0.6;\n          const progress = Math.min(1, u.progress + (u.bytesPerSecond * jitter * TICK) / 1000 / u.size);\n          if (u.failAt !== undefined && progress >= u.failAt) {\n            return { ...u, progress: u.failAt, status: \"error\", failAt: undefined };\n          }\n          return { ...u, progress, status: progress >= 1 ? \"done\" : \"uploading\" };\n        }),\n      );\n    }, TICK);\n    return () => clearInterval(id);\n  }, [active]);\n\n  const retry = (id: string) => {\n    setUploads((list) =>\n      list.map((u) => {\n        if (u.id !== id) return u;\n        const attempt = u.attempt + 1;\n        const failAt = shouldFailRef.current(u, attempt) ? 0.35 + Math.random() * 0.3 : undefined;\n        return { ...u, attempt, failAt, progress: 0, status: \"uploading\" };\n      }),\n    );\n  };\n\n  const remove = (id: string) => setUploads((list) => list.filter((u) => u.id !== id));\n\n  const takeFiles = (list: FileList | null) => {\n    if (!list?.length) return;\n    const files = [...list];\n    onFiles?.(files);\n    add(files.map((f) => ({ name: f.name, size: Math.max(f.size, 200_000) })));\n  };\n\n  const onDrag = (event: DragEvent, kind: \"enter\" | \"leave\" | \"over\" | \"drop\") => {\n    event.preventDefault();\n    if (kind === \"enter\") {\n      depth.current += 1;\n      setDragging(true);\n    } else if (kind === \"leave\") {\n      depth.current = Math.max(0, depth.current - 1);\n      if (depth.current === 0) setDragging(false);\n    } else if (kind === \"drop\") {\n      depth.current = 0;\n      setDragging(false);\n      takeFiles(event.dataTransfer.files);\n    }\n  };\n\n  const done = uploads.filter((u) => u.status === \"done\").length;\n\n  return (\n    <div className={cn(\"grid w-full max-w-md gap-3\", className)}>\n      <label\n        className={cn(\n          \"group/drop relative flex cursor-pointer flex-col items-center gap-2 overflow-hidden rounded-2xl border-2 border-dashed bg-card px-6 py-8 text-center outline-none transition-[border-color,background-color] duration-200 has-focus-visible:ring-2 has-focus-visible:ring-ring\",\n          dragging ? \"border-primary bg-accent\" : \"border-border hover:border-ring/60\",\n        )}\n        htmlFor={inputId}\n        onDragEnter={(e) => onDrag(e, \"enter\")}\n        onDragLeave={(e) => onDrag(e, \"leave\")}\n        onDragOver={(e) => onDrag(e, \"over\")}\n        onDrop={(e) => onDrag(e, \"drop\")}\n      >\n        <motion.span\n          animate={dragging && !reduce ? { y: [-2, -8, -2], scale: 1.08 } : { y: 0, scale: 1 }}\n          className={cn(\n            \"flex size-11 items-center justify-center rounded-full transition-colors duration-200 [&_svg]:size-5\",\n            dragging ? \"bg-primary text-primary-foreground\" : \"bg-muted text-muted-foreground\",\n          )}\n          transition={dragging ? { y: { repeat: Infinity, duration: 1.1, ease: \"easeInOut\" } } : { type: \"spring\", stiffness: 300, damping: 18 }}\n        >\n          <CloudUploadIcon />\n        </motion.span>\n        <span className=\"font-medium text-sm\">\n          {dragging ? \"Let go to upload\" : (\n            <>\n              Drop files here or <span className=\"underline underline-offset-4\">browse</span>\n            </>\n          )}\n        </span>\n        <span className=\"text-muted-foreground text-xs\">{hint}</span>\n        <input\n          accept={accept}\n          className=\"sr-only\"\n          id={inputId}\n          multiple\n          onChange={(e) => {\n            takeFiles(e.target.files);\n            e.target.value = \"\";\n          }}\n          type=\"file\"\n        />\n      </label>\n\n      {uploads.length > 0 && (\n        <div className=\"flex items-center justify-between px-1 text-muted-foreground text-xs\">\n          <span>\n            <span className=\"text-foreground tabular-nums\">{done}</span> of <span className=\"tabular-nums\">{uploads.length}</span> uploaded\n          </span>\n          {done > 0 && (\n            <button\n              className=\"cursor-pointer rounded underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={() => setUploads((list) => list.filter((u) => u.status !== \"done\"))}\n              type=\"button\"\n            >\n              Clear finished\n            </button>\n          )}\n        </div>\n      )}\n\n      <ul className=\"grid gap-2\">\n        <AnimatePresence initial={false}>\n          {uploads.map((u) => (\n            <UploadCard key={u.id} onRemove={() => remove(u.id)} onRetry={() => retry(u.id)} upload={u} />\n          ))}\n        </AnimatePresence>\n      </ul>\n    </div>\n  );\n}\n\nfunction UploadCard({ upload: u, onRetry, onRemove }: { upload: Upload; onRetry: () => void; onRemove: () => void }) {\n  const reduce = useReducedMotion();\n  const kind = kindOf(u.name);\n  const remaining = (u.size * (1 - u.progress)) / u.bytesPerSecond;\n\n  return (\n    <motion.li\n      animate={{ opacity: 1, y: 0, rotate: 0, scale: 1 }}\n      className={cn(\n        \"relative flex items-center gap-3 rounded-xl border bg-card p-3 shadow-xs/5 transition-colors\",\n        u.status === \"error\" && \"border-destructive/40 bg-destructive/4\",\n      )}\n      exit={{ opacity: 0, scale: 0.94, x: 24, transition: { duration: 0.18 } }}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: -24, rotate: u.tilt, scale: 0.95 }}\n      layout={!reduce}\n      transition={{ type: \"spring\", stiffness: 420, damping: 32 }}\n    >\n      <span\n        className={cn(\n          \"relative flex h-10 w-8 shrink-0 items-end justify-center rounded-md pb-1.5 font-bold font-mono text-[9px] tracking-wide [clip-path:polygon(0_0,70%_0,100%_22%,100%_100%,0_100%)]\",\n          kind.className,\n        )}\n      >\n        <span aria-hidden className=\"absolute top-0 right-0 size-2.5 rounded-bl-sm bg-background/35\" />\n        {kind.label}\n      </span>\n      <div className=\"grid min-w-0 flex-1 gap-1.5\">\n        <div className=\"flex items-baseline justify-between gap-2\">\n          <span className=\"truncate font-medium text-sm\">{u.name}</span>\n          <span className=\"shrink-0 text-muted-foreground text-xs tabular-nums\">\n            {u.status === \"uploading\" && `${Math.round(u.progress * 100)}%`}\n          </span>\n        </div>\n        <div\n          aria-label={`${u.name} upload progress`}\n          aria-valuemax={100}\n          aria-valuemin={0}\n          aria-valuenow={Math.round(u.progress * 100)}\n          className={cn(\n            \"h-1.5 overflow-hidden rounded-full bg-muted transition-[height,opacity,margin] duration-500\",\n            u.status === \"done\" && \"-my-0.75 h-0 opacity-0\",\n          )}\n          role=\"progressbar\"\n        >\n          <motion.div\n            animate={{ width: `${u.progress * 100}%` }}\n            className={cn(\"h-full rounded-full\", u.status === \"error\" ? \"bg-destructive\" : \"bg-primary\")}\n            initial={false}\n            transition={{ type: \"spring\", stiffness: 120, damping: 20 }}\n          />\n        </div>\n        <span className=\"-mt-0.5 text-muted-foreground text-xs tabular-nums\" aria-live=\"polite\">\n          {u.status === \"uploading\" && (\n            <>\n              {formatBytes(u.size * u.progress)} of {formatBytes(u.size)} · {formatEta(remaining)}\n            </>\n          )}\n          {u.status === \"done\" && <>Uploaded · {formatBytes(u.size)}</>}\n          {u.status === \"error\" && <span className=\"text-destructive-foreground\">Connection lost at {Math.round(u.progress * 100)}%</span>}\n        </span>\n      </div>\n      <div className=\"flex shrink-0 items-center gap-1\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {u.status === \"done\" && (\n            <motion.span\n              animate={{ scale: 1, opacity: 1 }}\n              className=\"flex size-6 items-center justify-center rounded-full bg-success text-white [&_svg]:size-3.5\"\n              exit={{ scale: 0.6, opacity: 0 }}\n              initial={reduce ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}\n              key=\"done\"\n              transition={{ type: \"spring\", stiffness: 520, damping: 26 }}\n            >\n              <CheckIcon strokeWidth={3} />\n            </motion.span>\n          )}\n          {u.status === \"error\" && (\n            <motion.button\n              animate={{ scale: 1, opacity: 1 }}\n              aria-label={`Retry ${u.name}`}\n              className=\"flex h-8 cursor-pointer items-center gap-1 rounded-lg border bg-popover px-2.5 font-medium text-xs outline-none transition-[background-color,scale] hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] [&_svg]:size-3.5\"\n              exit={{ scale: 0.9, opacity: 0 }}\n              initial={reduce ? { opacity: 0 } : { scale: 0.9, opacity: 0 }}\n              key=\"retry\"\n              transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}\n              onClick={onRetry}\n              type=\"button\"\n            >\n              <RotateCwIcon />\n              Retry\n            </motion.button>\n          )}\n        </AnimatePresence>\n        <button\n          aria-label={`Remove ${u.name}`}\n          className=\"flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-[background-color,color,scale] hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] [&_svg]:size-3.5\"\n          onClick={onRemove}\n          type=\"button\"\n        >\n          <XIcon />\n        </button>\n      </div>\n    </motion.li>\n  );\n}\n"
    }
  ]
}