{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload-staging",
  "type": "registry:component",
  "title": "File Upload Staging Area",
  "description": "Per-tile interruptible upload state machines with capped concurrency and a layout-glide grid reflow.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://lab.moumen.dev/r/lab-theme.json"
  ],
  "files": [
    {
      "path": "registry/lab/file-upload-staging/file-upload-staging.tsx",
      "type": "registry:component",
      "target": "@components/lab/file-upload-staging.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useImperativeHandle, useRef, useState, type Ref } from \"react\";\nimport { MotionConfig, motion } from \"motion/react\";\n\n// File upload staging area - a pipeline expressed through motion.\n//\n// Three hard problems, none of them the dropzone:\n//\n//   · A reflowing GRID that never jump-cuts. Tiles are motion.li with `layout`:\n//     removing a tile mid-grid makes every later tile glide up and across, and\n//     entering tiles scale + unblur in. Exits stay subtler than enters - here,\n//     instant.\n//   · An interruptible state machine PER TILE: queued → uploading → done, or\n//     → error frozen at the failure percent. Retry RESUMES from that percent\n//     (the arc never lies by rewinding), removal works in any state, and a\n//     freed slot immediately promotes the next queued tile - concurrency is\n//     capped at 2 so the pipeline is visible instead of everything blasting\n//     to 100% at once.\n//   · Honest jittered progress. One shared ticker advances every uploading\n//     tile by a random 0.6-4% with a 12% chance to stall a beat - variable\n//     like a real network - but progress NEVER moves backwards and 100% is\n//     the only way to complete. Failures are decided up front (a hidden\n//     failAt percent), not rolled per frame.\n//\n// TWO MODES, one pipeline:\n//\n//   · No `upload` prop → the built-in simulation (nothing leaves the page).\n//     Works out of the box; `failRate` shapes the demo.\n//   · An `upload` adapter → REAL uploads. The component keeps owning the queue,\n//     concurrency, retry and removal; your adapter owns the network:\n//\n//       <UploadStaging\n//         upload={async ({ file, name }, { onProgress, signal }) => {\n//           await api.upload(file!, { signal, onProgress }); // report 0-100\n//         }}\n//       />\n//\n//     Resolve → done. Throw → error, frozen at the last reported percent.\n//     Removing a tile mid-flight aborts via the AbortSignal. Retry re-invokes\n//     the adapter; the arc still never rewinds (progress is forward-only, so a\n//     restarted transfer catches up to the frozen percent before it moves).\n//\n// Real drag-and-drop and the file picker both work (the original File rides\n// along for the adapter); stage files programmatically through the ref handle:\n//\n//   const staging = useRef<UploadStagingHandle>(null);\n//   staging.current?.addFiles([{ name: \"report.pdf\", size: 842_000 }]);\n//\n// Long names middle-truncate to start…end.ext with the full name a hover away.\n// Animation via motion/react; honours prefers-reduced-motion. Requires the\n// lab-theme tokens. Fully Tailwind, no CSS files.\n\nconst EASE = [0.22, 1, 0.36, 1] as const;\nconst EASE_ICON = [0.2, 0, 0, 1] as const;\n\nexport interface UploadStagingHandle {\n  /** Stage files programmatically (name + size; kind is inferred from the extension). */\n  addFiles: (files: { name: string; size: number; kind?: FileKind; file?: File }[]) => void;\n}\n\nexport type FileKind = \"image\" | \"pdf\" | \"archive\" | \"doc\" | \"file\";\n\n/** Your network layer. Report 0-100 via onProgress; resolve on success, throw\n *  on failure; honour the signal so removing a tile cancels the transfer. */\nexport type UploadFn = (\n  file: { name: string; size: number; kind: FileKind; file?: File },\n  ctx: { onProgress: (percent: number) => void; signal: AbortSignal },\n) => Promise<void>;\n\nexport interface UploadStagingState {\n  total: number;\n  queued: number;\n  uploading: number;\n  done: number;\n  error: number;\n  bytes: number;\n}\n\ntype Status = \"queued\" | \"uploading\" | \"done\" | \"error\";\n\ninterface StagedFile {\n  id: string;\n  name: string;\n  size: number;\n  kind: FileKind;\n  file?: File;\n  status: Status;\n  progress: number;\n  failAt: number;\n}\n\nfunction kindOf(name: string): FileKind {\n  const ext = name.split(\".\").pop()?.toLowerCase() ?? \"\";\n  if ([\"png\", \"jpg\", \"jpeg\", \"gif\", \"webp\", \"svg\", \"avif\"].includes(ext)) return \"image\";\n  if (ext === \"pdf\") return \"pdf\";\n  if ([\"zip\", \"tar\", \"gz\", \"rar\", \"7z\"].includes(ext)) return \"archive\";\n  if ([\"doc\", \"docx\", \"txt\", \"md\", \"pages\"].includes(ext)) return \"doc\";\n  return \"file\";\n}\n\nfunction formatSize(bytes: number) {\n  if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;\n  return `${Math.max(1, Math.round(bytes / 1000))} KB`;\n}\n\n// Middle-truncate, keeping the start and the extension - a tail-ellipsis\n// would eat exactly the part that tells files apart.\nfunction truncateName(name: string, max = 14) {\n  if (name.length <= max) return name;\n  const head = name.slice(0, Math.ceil((max - 1) * 0.55));\n  const tail = name.slice(-(max - 1 - head.length));\n  return `${head}…${tail}`;\n}\n\n// Failure is decided when the upload STARTS (a hidden failAt percent), not\n// re-rolled per frame - the same file fails at the same point.\nfunction pickFailAt(rate: number, floor = 25) {\n  return Math.random() < rate ? floor + Math.random() * (88 - floor) : Infinity;\n}\n\nconst R = 13;\nconst CIRC = 2 * Math.PI * R;\n\nexport default function UploadStaging({\n  upload,\n  concurrency = 2,\n  failRate = 0.25,\n  morph = true,\n  inspect = false,\n  onStateChange,\n  ref,\n}: {\n  /** Your real uploader. Omit it and the component simulates the network. */\n  upload?: UploadFn;\n  /** How many transfers run at once; the rest wait in the visible queue. */\n  concurrency?: number;\n  /** Simulation only: chance a newly staged file's upload fails (0-1). */\n  failRate?: number;\n  /** Grid glides + entrances; false snaps every layout change. */\n  morph?: boolean;\n  inspect?: boolean;\n  onStateChange?: (state: UploadStagingState) => void;\n  ref?: Ref<UploadStagingHandle>;\n}) {\n  const [files, setFiles] = useState<StagedFile[]>([]);\n  const [dragOver, setDragOver] = useState(false);\n\n  const idRef = useRef(0);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  // Real mode: one AbortController per in-flight transfer, so removal cancels.\n  const activeRef = useRef(new Map<string, AbortController>());\n\n  function addFiles(metas: { name: string; size: number; kind?: FileKind; file?: File }[]) {\n    setFiles((list) => [\n      ...list,\n      ...metas.map((meta) => ({\n        id: `f${(idRef.current += 1)}`,\n        name: meta.name,\n        size: meta.size,\n        kind: meta.kind ?? kindOf(meta.name),\n        file: meta.file,\n        status: \"queued\" as const,\n        progress: 0,\n        failAt: pickFailAt(failRate),\n      })),\n    ]);\n  }\n\n  useImperativeHandle(ref, () => ({ addFiles }), [failRate]);\n\n  function handleDrop(event: React.DragEvent) {\n    event.preventDefault();\n    setDragOver(false);\n    const dropped = [...(event.dataTransfer?.files ?? [])].map((file) => ({\n      name: file.name,\n      size: file.size,\n      file,\n    }));\n    if (dropped.length) addFiles(dropped);\n  }\n\n  // ── The pipeline, simulation mode ────────────────────────────────────\n  // One effect owns promotion AND progress. Freed slots promote the oldest\n  // queued tile; a shared 70ms tick advances every uploading tile with\n  // jitter and an occasional stall - forward only, done only at 100.\n  const anyActive = files.some((f) => f.status === \"uploading\" || f.status === \"queued\");\n  useEffect(() => {\n    if (upload || !anyActive) return undefined;\n    const tick = setInterval(() => {\n      setFiles((list) => {\n        let uploading = list.filter((f) => f.status === \"uploading\").length;\n        return list.map((file) => {\n          if (file.status === \"queued\" && uploading < concurrency) {\n            uploading += 1;\n            return { ...file, status: \"uploading\" as const };\n          }\n          if (file.status !== \"uploading\") return file;\n          if (Math.random() < 0.12) return file; // the stall - networks breathe\n          const progress = Math.min(100, file.progress + 0.6 + Math.random() * 3.4);\n          if (progress >= file.failAt) {\n            uploading -= 1;\n            return { ...file, status: \"error\" as const, progress: Math.round(file.failAt) };\n          }\n          if (progress >= 100) {\n            uploading -= 1;\n            return { ...file, status: \"done\" as const, progress: 100 };\n          }\n          return { ...file, progress };\n        });\n      });\n    }, 70);\n    return () => clearInterval(tick);\n  }, [upload, anyActive, concurrency]);\n\n  // ── The pipeline, real mode ──────────────────────────────────────────\n  // Same machine, your network. Freed slots promote queued tiles, then every\n  // uploading tile that isn't in flight yet gets its adapter call. Progress is\n  // still forward-only (the arc never rewinds - a retried transfer catches up\n  // to its frozen percent before the arc moves again).\n  useEffect(() => {\n    if (!upload) return;\n    const uploadingCount = files.filter((f) => f.status === \"uploading\").length;\n    if (uploadingCount < concurrency && files.some((f) => f.status === \"queued\")) {\n      setFiles((list) => {\n        let free = concurrency - list.filter((f) => f.status === \"uploading\").length;\n        return list.map((f) => (f.status === \"queued\" && free > 0 && (free -= 1) >= 0 ? { ...f, status: \"uploading\" as const } : f));\n      });\n      return;\n    }\n    for (const f of files) {\n      if (f.status !== \"uploading\" || activeRef.current.has(f.id)) continue;\n      const controller = new AbortController();\n      activeRef.current.set(f.id, controller);\n      const patch = (id: string, up: (x: StagedFile) => StagedFile) =>\n        setFiles((list) => list.map((x) => (x.id === id ? up(x) : x)));\n      upload(\n        { name: f.name, size: f.size, kind: f.kind, file: f.file },\n        {\n          onProgress: (percent) =>\n            patch(f.id, (x) =>\n              x.status === \"uploading\"\n                ? { ...x, progress: Math.min(100, Math.max(x.progress, percent)) }\n                : x,\n            ),\n          signal: controller.signal,\n        },\n      )\n        .then(() => {\n          activeRef.current.delete(f.id);\n          patch(f.id, (x) => ({ ...x, status: \"done\", progress: 100 }));\n        })\n        .catch(() => {\n          activeRef.current.delete(f.id);\n          if (controller.signal.aborted) return; // removed, not failed\n          patch(f.id, (x) => ({ ...x, status: \"error\", progress: Math.round(x.progress) }));\n        });\n    }\n  }, [upload, files, concurrency]);\n\n  // Abort everything in flight on unmount.\n  useEffect(\n    () => () => {\n      activeRef.current.forEach((controller) => controller.abort());\n      activeRef.current.clear();\n    },\n    [],\n  );\n\n  // Retry resumes from the frozen percent - the arc never rewinds. In the\n  // simulation a retry can still re-fail (15%), but only past the point it\n  // already reached; in real mode the adapter simply runs again.\n  function retry(id: string) {\n    setFiles((list) =>\n      list.map((file) =>\n        file.id === id\n          ? { ...file, status: \"queued\" as const, failAt: pickFailAt(0.15, Math.min(92, file.progress + 8)) }\n          : file,\n      ),\n    );\n  }\n\n  const retryAllFailed = () => files.filter((f) => f.status === \"error\").forEach((f) => retry(f.id));\n  const remove = (id: string) => {\n    activeRef.current.get(id)?.abort();\n    activeRef.current.delete(id);\n    setFiles((list) => list.filter((file) => file.id !== id));\n  };\n  const clearDone = () => setFiles((list) => list.filter((file) => file.status !== \"done\"));\n\n  const counts = {\n    queued: files.filter((f) => f.status === \"queued\").length,\n    uploading: files.filter((f) => f.status === \"uploading\").length,\n    done: files.filter((f) => f.status === \"done\").length,\n    error: files.filter((f) => f.status === \"error\").length,\n  };\n  useEffect(() => {\n    onStateChange?.({\n      total: files.length,\n      ...counts,\n      bytes: files.reduce((sum, f) => sum + f.size, 0),\n    });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [files.length, counts.queued, counts.uploading, counts.done, counts.error, onStateChange]);\n\n  const ghostBtnClass =\n    \"h-7 px-2.5 rounded-lg bg-muted text-foreground text-xs font-medium transition-[background-color,scale] duration-150 hover:bg-foreground/10 active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\";\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className=\"relative w-full max-w-[22rem] flex flex-col gap-3\" data-inspect={inspect ? \"true\" : \"false\"}>\n        {/* Dropzone: real drops and the picker both stage metadata only. */}\n        <div\n          className={[\n            \"flex flex-col items-center gap-1 px-4 py-[1.125rem] border-[1.5px] border-dashed rounded-xl text-center transition-[border-color,background-color] duration-150\",\n            dragOver ? \"border-foreground bg-muted/50\" : \"border-muted-foreground/50 bg-card\",\n          ].join(\" \")}\n          onDragOver={(event) => {\n            event.preventDefault();\n            setDragOver(true);\n          }}\n          onDragLeave={() => setDragOver(false)}\n          onDrop={handleDrop}\n        >\n          <span className=\"inline-flex text-muted-foreground/70\" aria-hidden=\"true\">\n            <UploadIcon />\n          </span>\n          <p className=\"m-0 text-[0.8125rem] text-muted-foreground\">\n            Drop files here or{\" \"}\n            <button\n              type=\"button\"\n              className=\"p-0 border-0 bg-transparent text-foreground text-[length:inherit] font-medium underline underline-offset-[3px] decoration-muted-foreground/50 cursor-pointer transition-[text-decoration-color] duration-150 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:rounded-[2px]\"\n              onClick={() => fileInputRef.current?.click()}\n            >\n              browse\n            </button>\n          </p>\n          <p className=\"m-0 text-[0.6875rem] text-muted-foreground/50\">Nothing is uploaded anywhere - the network is simulated</p>\n          <input\n            ref={fileInputRef}\n            type=\"file\"\n            multiple\n            className=\"sr-only\"\n            aria-label=\"Choose files to stage\"\n            onChange={(event) => {\n              const picked = [...(event.target.files ?? [])].map((file) => ({ name: file.name, size: file.size }));\n              if (picked.length) addFiles(picked);\n              event.target.value = \"\";\n            }}\n          />\n        </div>\n\n        {/* The staging grid. `layout` on every tile: on any list change the\n            survivors glide to their new spots (the FLIP, without the FLIP). */}\n        {files.length > 0 && (\n          <ul className=\"grid grid-cols-[repeat(auto-fill,minmax(6.25rem,1fr))] gap-2 m-0 p-0 list-none\">\n            {files.map((file) => (\n              <motion.li\n                key={file.id}\n                layout={morph}\n                initial={morph ? { opacity: 0, scale: 0.95, filter: \"blur(2px)\" } : false}\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                transition={{ layout: { duration: 0.3, ease: EASE }, duration: 0.24, ease: EASE }}\n                className={[\n                  \"group/tile relative flex flex-col items-center gap-0.5 pt-3 px-2 pb-2.5 rounded-[0.625rem] transition-[box-shadow,background-color] duration-[350ms]\",\n                  file.status === \"done\"\n                    ? \"shadow-[var(--shadow-border),0_0_0_1.5px_#86efac] bg-[#fcfefc]\"\n                    : file.status === \"error\"\n                      ? \"shadow-[var(--shadow-border),0_0_0_1.5px_color-mix(in_oklab,var(--color-destructive)_40%,transparent)] bg-card\"\n                      : \"shadow-border bg-card\",\n                  inspect ? \"outline outline-[1.5px] outline-dashed outline-[#3b82f6] outline-offset-2\" : \"\",\n                ].join(\" \")}\n                data-status={file.status}\n              >\n                <button\n                  type=\"button\"\n                  className=\"absolute top-1 right-1 z-[1] inline-flex items-center justify-center w-5 h-5 rounded-[0.3125rem] text-muted-foreground/50 opacity-0 transition-[opacity,background-color,color,scale] duration-150 group-hover/tile:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 hover:bg-accent hover:text-foreground active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring after:content-[''] after:absolute after:-inset-1.5\"\n                  aria-label={`Remove ${file.name}`}\n                  onClick={() => remove(file.id)}\n                >\n                  <XIcon />\n                </button>\n\n                {/* The visual: type icon inside the progress ring; the check or\n                    the retry button takes over the same footprint. */}\n                <span className=\"relative w-[34px] h-[34px] inline-flex items-center justify-center\">\n                  <svg className=\"absolute inset-0 -rotate-90\" width=\"34\" height=\"34\" viewBox=\"0 0 34 34\" aria-hidden=\"true\">\n                    <circle className=\"fill-none stroke-muted stroke-[2.5]\" cx=\"17\" cy=\"17\" r={R} />\n                    <circle\n                      className={[\n                        // linear on purpose: the jitter IS the texture, easing would fake it\n                        \"fill-none stroke-[2.5] [stroke-linecap:round] [transition:stroke-dashoffset_140ms_linear,stroke_200ms_ease,opacity_250ms_ease]\",\n                        file.status === \"queued\"\n                          ? \"stroke-muted-foreground/50\"\n                          : file.status === \"error\"\n                            ? \"stroke-destructive\"\n                            : file.status === \"done\"\n                              ? \"stroke-foreground opacity-0\"\n                              : \"stroke-foreground\",\n                      ].join(\" \")}\n                      cx=\"17\"\n                      cy=\"17\"\n                      r={R}\n                      strokeDasharray={CIRC}\n                      strokeDashoffset={CIRC * (1 - file.progress / 100)}\n                    />\n                  </svg>\n                  {/* Contextual swap: the kind icon ducks out (opacity + scale +\n                      blur) when the check or retry takes the footprint. */}\n                  <motion.span\n                    className=\"inline-flex text-muted-foreground\"\n                    initial={false}\n                    animate={\n                      file.status === \"done\" || file.status === \"error\"\n                        ? { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }\n                        : { opacity: 1, scale: 1, filter: \"blur(0px)\" }\n                    }\n                    transition={{ duration: 0.2, ease: EASE_ICON }}\n                  >\n                    <KindIcon kind={file.kind} />\n                  </motion.span>\n                  {file.status === \"done\" && (\n                    <svg className=\"absolute inset-0\" width=\"34\" height=\"34\" viewBox=\"0 0 34 34\" aria-hidden=\"true\">\n                      {/* The settle: the check draws itself in. */}\n                      <motion.path\n                        className=\"fill-none stroke-[#16a34a] stroke-[2.5] [stroke-linecap:round] [stroke-linejoin:round]\"\n                        d=\"M11 17.5l4 4 8-9\"\n                        initial={{ pathLength: 0 }}\n                        animate={{ pathLength: 1 }}\n                        transition={{ duration: 0.35, ease: EASE, delay: 0.12 }}\n                      />\n                    </svg>\n                  )}\n                  {file.status === \"error\" && (\n                    <motion.button\n                      type=\"button\"\n                      className=\"absolute inset-0 inline-flex items-center justify-center rounded-full text-destructive transition-[background-color,scale] duration-150 hover:bg-destructive/10 active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive after:content-[''] after:absolute after:-inset-1\"\n                      aria-label={`Retry ${file.name}, failed at ${file.progress}%`}\n                      onClick={() => retry(file.id)}\n                      initial={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n                      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                      transition={{ duration: 0.2, ease: EASE_ICON }}\n                    >\n                      <RetryIcon />\n                    </motion.button>\n                  )}\n                </span>\n\n                <span\n                  className=\"max-w-full overflow-hidden whitespace-nowrap text-ellipsis text-[0.6875rem] font-medium text-foreground\"\n                  title={file.name}\n                >\n                  {truncateName(file.name)}\n                </span>\n                <span\n                  className={`text-[0.625rem] min-h-[0.9375rem] tabular-nums ${\n                    file.status === \"error\" ? \"text-destructive\" : \"text-muted-foreground/70\"\n                  }`}\n                >\n                  {file.status === \"uploading\" && `${Math.floor(file.progress)}%`}\n                  {file.status === \"queued\" && \"queued\"}\n                  {file.status === \"done\" && formatSize(file.size)}\n                  {file.status === \"error\" && `failed at ${file.progress}%`}\n                </span>\n              </motion.li>\n            ))}\n          </ul>\n        )}\n\n        {/* Pipeline footer - batch actions double as multi-tile glide demos. */}\n        {files.length > 0 && (\n          <div className=\"flex items-center justify-between gap-2 min-h-7\">\n            <span className=\"text-xs text-muted-foreground tabular-nums\">\n              {counts.done}/{files.length} uploaded\n              {counts.error > 0 && <span className=\"text-destructive font-medium\"> · {counts.error} failed</span>}\n            </span>\n            <span className=\"inline-flex gap-1.5\">\n              {counts.error > 0 && (\n                <motion.button\n                  type=\"button\"\n                  className={ghostBtnClass}\n                  onClick={retryAllFailed}\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  transition={{ duration: 0.2, ease: EASE_ICON }}\n                >\n                  Retry failed\n                </motion.button>\n              )}\n              {counts.done > 0 && (\n                <motion.button\n                  type=\"button\"\n                  className={ghostBtnClass}\n                  onClick={clearDone}\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  transition={{ duration: 0.2, ease: EASE_ICON }}\n                >\n                  Clear done\n                </motion.button>\n              )}\n            </span>\n          </div>\n        )}\n\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {counts.error > 0\n            ? `${counts.error} ${counts.error === 1 ? \"upload\" : \"uploads\"} failed.`\n            : counts.uploading > 0\n              ? `Uploading ${counts.uploading} of ${files.length}.`\n              : files.length > 0 && counts.done === files.length\n                ? \"All uploads finished.\"\n                : \"\"}\n        </span>\n\n        {/* Blueprint annotations (blue = the grid glide, red = the pipeline). */}\n        {inspect && (\n          <>\n            <span className=\"absolute bottom-[calc(100%+0.4rem)] left-0 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#bfdbfe] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#2563eb] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums\">\n              tiles keyed by id · layout glide 300ms on reflow\n            </span>\n            <span className=\"absolute top-[calc(100%+0.4rem)] left-0 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#fecaca] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#dc2626] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums\">\n              queued → uploading → done | error (frozen) → resume · concurrency {concurrency}\n            </span>\n          </>\n        )}\n      </div>\n    </MotionConfig>\n  );\n}\n\nfunction Svg({ children, size = 15, sw = 1.8 }: { children: React.ReactNode; size?: number; sw?: number }) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={sw}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      {children}\n    </svg>\n  );\n}\n\nfunction KindIcon({ kind }: { kind: FileKind }) {\n  if (kind === \"image\")\n    return (\n      <Svg size={14}>\n        <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" />\n        <circle cx=\"9\" cy=\"9\" r=\"2\" />\n        <path d=\"m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21\" />\n      </Svg>\n    );\n  if (kind === \"pdf\")\n    return (\n      <Svg size={14}>\n        <path d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5Z\" />\n        <path d=\"M14 2v4a2 2 0 0 0 2 2h4\" />\n      </Svg>\n    );\n  if (kind === \"archive\")\n    return (\n      <Svg size={14}>\n        <rect x=\"2\" y=\"3\" width=\"20\" height=\"5\" rx=\"1\" />\n        <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4\" />\n      </Svg>\n    );\n  if (kind === \"doc\")\n    return (\n      <Svg size={14}>\n        <path d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5Z\" />\n        <path d=\"M14 2v4a2 2 0 0 0 2 2h4M8 13h8M8 17h5\" />\n      </Svg>\n    );\n  return (\n    <Svg size={14}>\n      <path d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5Z\" />\n      <path d=\"M14 2v4a2 2 0 0 0 2 2h4\" />\n    </Svg>\n  );\n}\n\nfunction UploadIcon() {\n  return (\n    <Svg size={18}>\n      <path d=\"M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8\" />\n      <path d=\"m16 6-4-4-4 4M12 2v13\" />\n    </Svg>\n  );\n}\n\nfunction XIcon() {\n  return (\n    <Svg size={11} sw={2.2}>\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </Svg>\n  );\n}\n\nfunction RetryIcon() {\n  return (\n    <Svg size={13} sw={2}>\n      <path d=\"M21 12a9 9 0 1 1-2.64-6.36\" />\n      <path d=\"M21 3v6h-6\" />\n    </Svg>\n  );\n}\n"
    }
  ]
}
