{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upload-multiple-files",
  "type": "registry:component",
  "title": "Upload Multiple Files",
  "description": "Upload multiple files to a storage provider using a presigned upload URL.",
  "registryDependencies": [
    "https://coss.com/origin/r/comp-553.json"
  ],
  "files": [
    {
      "path": "registry/default/storage/upload-multiple-files.tsx",
      "content": "import {\n  AlertCircleIcon,\n  FileArchiveIcon,\n  FileIcon,\n  FileSpreadsheetIcon,\n  FileTextIcon,\n  HeadphonesIcon,\n  ImageIcon,\n  Trash2Icon,\n  UploadIcon,\n  VideoIcon,\n  XIcon,\n} from 'lucide-react';\nimport { useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport {\n  type FileWithPreview,\n  formatBytes,\n  useFileUpload,\n} from '@/hooks/use-file-upload';\nimport { useUploadFile } from '@/registry/default/storage/use-upload-file';\n\nconst getFileIcon = (file: { file: File | { type: string; name: string } }) => {\n  const fileType = file.file instanceof File ? file.file.type : file.file.type;\n  const fileName = file.file instanceof File ? file.file.name : file.file.name;\n\n  const iconMap = {\n    pdf: {\n      icon: FileTextIcon,\n      conditions: (type: string, name: string) =>\n        type.includes('pdf') ||\n        name.endsWith('.pdf') ||\n        type.includes('word') ||\n        name.endsWith('.doc') ||\n        name.endsWith('.docx'),\n    },\n    archive: {\n      icon: FileArchiveIcon,\n      conditions: (type: string, name: string) =>\n        type.includes('zip') ||\n        type.includes('archive') ||\n        name.endsWith('.zip') ||\n        name.endsWith('.rar'),\n    },\n    excel: {\n      icon: FileSpreadsheetIcon,\n      conditions: (type: string, name: string) =>\n        type.includes('excel') ||\n        name.endsWith('.xls') ||\n        name.endsWith('.xlsx'),\n    },\n    video: {\n      icon: VideoIcon,\n      conditions: (type: string) => type.includes('video/'),\n    },\n    audio: {\n      icon: HeadphonesIcon,\n      conditions: (type: string) => type.includes('audio/'),\n    },\n    image: {\n      icon: ImageIcon,\n      conditions: (type: string) => type.startsWith('image/'),\n    },\n  };\n\n  for (const { icon: Icon, conditions } of Object.values(iconMap)) {\n    if (conditions(fileType, fileName)) {\n      return <Icon className=\"size-5 opacity-60\" />;\n    }\n  }\n\n  return <FileIcon className=\"size-5 opacity-60\" />;\n};\n\nconst getFilePreview = (file: {\n  file: File | { type: string; name: string; url?: string };\n}) => {\n  const fileType = file.file instanceof File ? file.file.type : file.file.type;\n  const fileName = file.file instanceof File ? file.file.name : file.file.name;\n\n  const renderImage = (src: string) => (\n    <img\n      alt={fileName}\n      className=\"size-full rounded-t-[inherit] object-cover\"\n      src={src}\n    />\n  );\n\n  return (\n    <div className=\"flex aspect-square items-center justify-center overflow-hidden rounded-t-[inherit] bg-accent\">\n      {fileType.startsWith('image/') ? (\n        file.file instanceof File ? (\n          (() => {\n            const previewUrl = URL.createObjectURL(file.file);\n            return renderImage(previewUrl);\n          })()\n        ) : file.file.url ? (\n          renderImage(file.file.url)\n        ) : (\n          <ImageIcon className=\"size-5 opacity-60\" />\n        )\n      ) : (\n        getFileIcon(file)\n      )}\n    </div>\n  );\n};\n\ntype UploadProgress = {\n  fileId: string;\n  progress: number;\n  completed: boolean;\n  error?: string;\n  fileUrl?: string;\n};\n\nconst BYTES_PER_KB = 1024;\nconst BYTES_PER_MB = BYTES_PER_KB * BYTES_PER_KB;\nconst MAX_SIZE_MB = 5;\nconst MAX_FILES = 6;\n\ntype FileItemProps = {\n  file: FileWithPreview;\n  uploadProgress?: UploadProgress;\n  onRemove: (fileId: string) => void;\n};\n\nconst FileItem = ({ file, uploadProgress, onRemove }: FileItemProps) => {\n  const isUploading = uploadProgress && !uploadProgress.completed;\n\n  return (\n    <div\n      className=\"flex flex-col gap-1 rounded-lg border bg-background p-2 pe-3 transition-opacity duration-300\"\n      data-uploading={isUploading || undefined}\n    >\n      <div className=\"flex items-center justify-between gap-2\">\n        <div className=\"flex items-center gap-3 overflow-hidden in-data-[uploading=true]:opacity-50\">\n          <div className=\"flex aspect-square size-10 shrink-0 items-center justify-center overflow-hidden rounded border\">\n            {(file.file instanceof File\n              ? file.file.type\n              : file.file.type\n            ).startsWith('image/')\n              ? getFilePreview(file)\n              : getFileIcon(file)}\n          </div>\n          <div className=\"flex min-w-0 flex-col gap-0.5\">\n            <p className=\"truncate font-medium text-[13px]\">\n              {file.file instanceof File ? file.file.name : file.file.name}\n            </p>\n            <p className=\"text-muted-foreground text-xs\">\n              {formatBytes(\n                file.file instanceof File ? file.file.size : file.file.size,\n              )}\n            </p>\n          </div>\n        </div>\n        <Button\n          aria-label=\"Remove file\"\n          className=\"-me-2 size-8 text-muted-foreground/80 hover:bg-transparent hover:text-foreground\"\n          onClick={() => onRemove(file.id)}\n          size=\"icon\"\n          variant=\"ghost\"\n        >\n          <XIcon aria-hidden=\"true\" className=\"size-4\" />\n        </Button>\n      </div>\n\n      {uploadProgress &&\n        (() => {\n          const progress = uploadProgress.progress || 0;\n          const completed = uploadProgress.completed;\n          const hasError = uploadProgress.error;\n\n          if (completed && !hasError) {\n            return null;\n          }\n\n          return (\n            <div className=\"mt-1 flex items-center gap-2\">\n              <div className=\"h-1.5 w-full overflow-hidden rounded-full bg-gray-100\">\n                <div\n                  className={`h-full transition-all duration-300 ease-out ${\n                    hasError ? 'bg-destructive' : 'bg-primary'\n                  }`}\n                  style={{ width: `${progress}%` }}\n                />\n              </div>\n              <span className=\"w-10 text-muted-foreground text-xs tabular-nums\">\n                {hasError ? 'Error' : `${progress}%`}\n              </span>\n            </div>\n          );\n        })()}\n    </div>\n  );\n};\n\nexport default function UploadMultipleFiles() {\n  const maxSize = MAX_SIZE_MB * BYTES_PER_MB;\n\n  const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);\n  const { uploadFile } = useUploadFile();\n\n  const handleFilesAdded = (addedFiles: FileWithPreview[]) => {\n    const newProgressItems = addedFiles.map((file) => ({\n      fileId: file.id,\n      progress: 0,\n      completed: false,\n    }));\n\n    setUploadProgress((prev) => [...prev, ...newProgressItems]);\n\n    for (const file of addedFiles) {\n      if (file.file instanceof File) {\n        uploadFile({\n          file: file.file,\n          onProgress: (progress) => {\n            setUploadProgress((prev) =>\n              prev.map((item) =>\n                item.fileId === file.id ? { ...item, progress } : item,\n              ),\n            );\n          },\n          onSuccess: (fileUrl) => {\n            setUploadProgress((prev) =>\n              prev.map((item) =>\n                item.fileId === file.id\n                  ? { ...item, completed: true, fileUrl }\n                  : item,\n              ),\n            );\n          },\n          onError: (error) => {\n            setUploadProgress((prev) =>\n              prev.map((item) =>\n                item.fileId === file.id\n                  ? { ...item, error: error.message, completed: true }\n                  : item,\n              ),\n            );\n          },\n        });\n      }\n    }\n  };\n\n  const handleFileRemoved = (fileId: string) => {\n    setUploadProgress((prev) => prev.filter((item) => item.fileId !== fileId));\n  };\n\n  const [\n    { files, isDragging, errors },\n    {\n      handleDragEnter,\n      handleDragLeave,\n      handleDragOver,\n      handleDrop,\n      openFileDialog,\n      removeFile,\n      clearFiles,\n      getInputProps,\n    },\n  ] = useFileUpload({\n    multiple: true,\n    maxFiles: MAX_FILES,\n    maxSize,\n    onFilesAdded: handleFilesAdded,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {/* Drop area */}\n      <div\n        className=\"relative flex min-h-52 flex-col items-center not-data-[files]:justify-center overflow-hidden rounded-xl border border-input border-dashed p-4 transition-colors has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50 data-[dragging=true]:bg-accent/50\"\n        data-dragging={isDragging || undefined}\n        data-files={files.length > 0 || undefined}\n        onDragEnter={handleDragEnter}\n        onDragLeave={handleDragLeave}\n        onDragOver={handleDragOver}\n        onDrop={handleDrop}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter' || e.key === ' ') {\n            e.preventDefault();\n            openFileDialog();\n          }\n        }}\n      >\n        <input\n          {...getInputProps()}\n          aria-label=\"Upload image file\"\n          className=\"sr-only\"\n        />\n        {files.length > 0 ? (\n          <div className=\"flex w-full flex-col gap-3\">\n            <div className=\"flex items-center justify-between gap-2\">\n              <h3 className=\"truncate font-medium text-sm\">\n                Files ({files.length})\n              </h3>\n              <div className=\"flex gap-2\">\n                <Button onClick={openFileDialog} size=\"sm\" variant=\"outline\">\n                  <UploadIcon\n                    aria-hidden=\"true\"\n                    className=\"-ms-0.5 size-3.5 opacity-60\"\n                  />\n                  Add files\n                </Button>\n                <Button\n                  onClick={() => {\n                    setUploadProgress([]);\n                    clearFiles();\n                  }}\n                  size=\"sm\"\n                  variant=\"outline\"\n                >\n                  <Trash2Icon\n                    aria-hidden=\"true\"\n                    className=\"-ms-0.5 size-3.5 opacity-60\"\n                  />\n                  Remove all\n                </Button>\n              </div>\n            </div>\n\n            <div className=\"w-full space-y-2\">\n              {files.map((file) => (\n                <FileItem\n                  file={file}\n                  key={file.id}\n                  onRemove={(fileId) => {\n                    handleFileRemoved(fileId);\n                    removeFile(fileId);\n                  }}\n                  uploadProgress={uploadProgress.find(\n                    (p) => p.fileId === file.id,\n                  )}\n                />\n              ))}\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex flex-col items-center justify-center px-4 py-3 text-center\">\n            <div\n              aria-hidden=\"true\"\n              className=\"mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border bg-background\"\n            >\n              <ImageIcon className=\"size-4 opacity-60\" />\n            </div>\n            <p className=\"mb-1.5 font-medium text-sm\">Drop your files here</p>\n            <p className=\"text-muted-foreground text-xs\">\n              Max {MAX_FILES} files ∙ Up to {MAX_SIZE_MB}MB\n            </p>\n            <Button className=\"mt-4\" onClick={openFileDialog} variant=\"outline\">\n              <UploadIcon aria-hidden=\"true\" className=\"-ms-1 opacity-60\" />\n              Select files\n            </Button>\n          </div>\n        )}\n      </div>\n\n      {errors.length > 0 && (\n        <div\n          className=\"flex items-center gap-1 text-destructive text-xs\"\n          role=\"alert\"\n        >\n          <AlertCircleIcon className=\"size-3 shrink-0\" />\n          <span>{errors[0]}</span>\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/default/storage/use-upload-file.ts",
      "content": "import { useCallback } from 'react';\n\ntype UploadArgs = {\n  file: File;\n  onProgress?: (percent: number) => void;\n  onSuccess?: (fileUrl: string) => void;\n  onError?: (error: Error) => void;\n};\n\nexport function useUploadFile() {\n  const uploadFile = useCallback(\n    async ({ file, onProgress, onSuccess, onError }: UploadArgs) => {\n      try {\n        const presignRes = await fetch('http://localhost:8080/uploadFile', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            fileName: file.name,\n            contentLength: file.size,\n          }),\n        });\n\n        if (!presignRes.ok) {\n          throw new Error('Failed to get presigned URL');\n        }\n        const presign = await presignRes.json();\n\n        await new Promise<void>((resolve, reject) => {\n          const xhr = new XMLHttpRequest();\n          xhr.open('PUT', presign.uploadUrl);\n\n          xhr.setRequestHeader('Content-Type', file.type);\n\n          xhr.upload.onprogress = (evt) => {\n            if (evt.lengthComputable && onProgress) {\n              const PERCENTAGE_MULTIPLIER = 100;\n              const percent = Math.round(\n                (evt.loaded * PERCENTAGE_MULTIPLIER) / evt.total,\n              );\n              onProgress(percent);\n            }\n          };\n\n          xhr.onload = () => {\n            if (xhr.status >= 200 && xhr.status < 300) {\n              resolve();\n            } else {\n              reject(new Error('Upload failed'));\n            }\n          };\n\n          xhr.onerror = () => reject(new Error('Upload failed'));\n          xhr.send(file);\n        });\n\n        const fileUrl = presign.fileUrl as string;\n        onSuccess?.(fileUrl);\n        return fileUrl;\n      } catch (error) {\n        const uploadError =\n          error instanceof Error ? error : new Error('Upload failed');\n        onError?.(uploadError);\n        throw uploadError;\n      }\n    },\n    [],\n  );\n\n  return { uploadFile };\n}\n",
      "type": "registry:hook"
    }
  ],
  "docs": "This component downloads `comp-553.tsx` from coss. We are using the coss registry for the `use-file-upload.ts` hook. You can delete `comp-553.tsx` after downloading it. This component pairs nicely with the `generate-presigned-upload-url` item from this registry to generate a presigned upload URL for a file."
}