{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multipart-uploader",
  "type": "registry:component",
  "title": "Multipart Uploader",
  "description": "Multi-file queue uploader with bounded concurrency, live progress and cancel.",
  "dependencies": [
    "files-sdk",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "progress"
  ],
  "files": [
    {
      "path": "registry/files-sdk/multipart-uploader/multipart-uploader.tsx",
      "type": "registry:component",
      "target": "components/files-sdk/multipart-uploader.tsx",
      "content": "\"use client\";\n\nimport type { UseFilesResult } from \"files-sdk/react\";\nimport { FileIcon, Loader2Icon, UploadIcon, XIcon } from \"lucide-react\";\nimport { useCallback, useRef, useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { cn } from \"@/lib/utils\";\n\ntype QueueStatus = \"pending\" | \"uploading\" | \"success\" | \"error\";\n\ninterface QueueItem {\n  id: number;\n  file: File;\n  status: QueueStatus;\n  /** 0–1. */\n  progress: number;\n  key?: string;\n  error?: string;\n}\n\nexport interface MultipartUploaderProps {\n  /** A `useFiles()` instance — uploads through it. */\n  files: UseFilesResult;\n  /** Key prefix (folder) for explicit keys, e.g. `\"docs/\"`. Empty = server mints keys. */\n  prefix?: string;\n  /** `accept` attribute for the file input. */\n  accept?: string;\n  /** Parallel uploads. Default 3. */\n  concurrency?: number;\n  onUploaded?: (entry: { key: string; name: string }) => void;\n  className?: string;\n}\n\n/**\n * Multi-file queue uploader. Files upload with bounded concurrency and live\n * per-file progress; large files are chunked into a multipart upload by the\n * gateway transparently. Cancel aborts everything in flight.\n */\nexport const MultipartUploader = ({\n  files,\n  prefix = \"\",\n  accept,\n  concurrency = 3,\n  onUploaded,\n  className,\n}: MultipartUploaderProps) => {\n  const idRef = useRef(0);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [queue, setQueue] = useState<QueueItem[]>([]);\n  const [isUploading, setIsUploading] = useState(false);\n\n  const patch = useCallback((id: number, next: Partial<QueueItem>) => {\n    setQueue((prev) =>\n      prev.map((item) => (item.id === id ? { ...item, ...next } : item))\n    );\n  }, []);\n\n  const add = useCallback((list: FileList | null) => {\n    if (!list?.length) {\n      return;\n    }\n    const additions: QueueItem[] = [...list].map((file) => {\n      const id = idRef.current;\n      idRef.current += 1;\n      return { file, id, progress: 0, status: \"pending\" };\n    });\n    setQueue((prev) => [...prev, ...additions]);\n  }, []);\n\n  const start = useCallback(async () => {\n    const pending = queue.filter((item) => item.status === \"pending\");\n    if (!pending.length) {\n      return;\n    }\n    // Re-arm the hook in case a previous run was cancelled (aborted) controller.\n    files.reset();\n    setIsUploading(true);\n\n    let cursor = 0;\n    const worker = async () => {\n      while (cursor < pending.length) {\n        const item = pending[cursor];\n        cursor += 1;\n        patch(item.id, { error: undefined, status: \"uploading\" });\n        try {\n          const onProgress = (p: { fraction: number }) =>\n            patch(item.id, { progress: p.fraction });\n          const result = prefix\n            ? // eslint-disable-next-line no-await-in-loop -- bounded-concurrency worker pulls items from a shared queue; each upload runs in order within its worker\n              await files.upload(`${prefix}${item.file.name}`, item.file, {\n                contentType: item.file.type,\n                onProgress,\n              })\n            : // eslint-disable-next-line no-await-in-loop -- bounded-concurrency worker pulls items from a shared queue; each upload runs in order within its worker\n              await files.upload(item.file, { onProgress });\n          patch(item.id, { key: result.key, progress: 1, status: \"success\" });\n          onUploaded?.({ key: result.key, name: item.file.name });\n        } catch (error) {\n          patch(item.id, {\n            error: error instanceof Error ? error.message : \"Upload failed\",\n            status: \"error\",\n          });\n        }\n      }\n    };\n\n    await Promise.all(\n      Array.from({ length: Math.min(concurrency, pending.length) }, worker)\n    );\n    setIsUploading(false);\n  }, [concurrency, files, onUploaded, patch, prefix, queue]);\n\n  const cancel = useCallback(() => {\n    files.abort();\n    setIsUploading(false);\n    setQueue((prev) =>\n      prev.map((item) =>\n        item.status === \"uploading\"\n          ? { ...item, error: \"Cancelled\", status: \"error\" }\n          : item\n      )\n    );\n  }, [files]);\n\n  const remove = useCallback((id: number) => {\n    setQueue((prev) => prev.filter((item) => item.id !== id));\n  }, []);\n\n  const pendingCount = queue.filter((item) => item.status === \"pending\").length;\n\n  return (\n    <div className={cn(\"flex flex-col gap-3\", className)}>\n      <Button\n        className=\"flex h-auto flex-col items-center justify-center gap-2 p-6\"\n        onClick={() => inputRef.current?.click()}\n        type=\"button\"\n        variant=\"outline\"\n      >\n        <input\n          accept={accept}\n          aria-label=\"Add files\"\n          className=\"hidden\"\n          multiple\n          onChange={(event) => {\n            add(event.currentTarget.files);\n            event.currentTarget.value = \"\";\n          }}\n          ref={inputRef}\n          type=\"file\"\n        />\n        <UploadIcon className=\"size-5 text-muted-foreground\" />\n        <span className=\"font-medium text-sm\">Add files</span>\n        <span className=\"text-muted-foreground text-xs\">\n          Large files upload in parts automatically.\n        </span>\n      </Button>\n\n      {queue.length > 0 && (\n        <ul className=\"flex flex-col gap-2\">\n          {queue.map((item) => (\n            <li className=\"flex flex-col gap-1.5\" key={item.id}>\n              <div className=\"flex items-center gap-2 text-sm\">\n                <FileIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n                <span className=\"min-w-0 flex-1 truncate\">\n                  {item.file.name}\n                </span>\n                {item.status === \"uploading\" ? (\n                  <Loader2Icon className=\"size-4 animate-spin text-muted-foreground\" />\n                ) : (\n                  <Button\n                    className=\"text-muted-foreground\"\n                    onClick={() => remove(item.id)}\n                    size=\"icon-xs\"\n                    type=\"button\"\n                    variant=\"ghost\"\n                  >\n                    <XIcon />\n                  </Button>\n                )}\n              </div>\n              {(item.status === \"uploading\" || item.status === \"success\") && (\n                <Progress value={item.progress * 100} />\n              )}\n              {item.error && (\n                <p className=\"text-destructive text-xs\">{item.error}</p>\n              )}\n            </li>\n          ))}\n        </ul>\n      )}\n\n      {queue.length > 0 && (\n        <div className=\"flex gap-2\">\n          <Button\n            disabled={!pendingCount || isUploading}\n            onClick={() => void start()}\n            type=\"button\"\n          >\n            {isUploading\n              ? \"Uploading…\"\n              : `Upload ${pendingCount} file${pendingCount === 1 ? \"\" : \"s\"}`}\n          </Button>\n          {isUploading && (\n            <Button onClick={cancel} type=\"button\" variant=\"outline\">\n              Cancel\n            </Button>\n          )}\n        </div>\n      )}\n    </div>\n  );\n};\n"
    }
  ]
}
