{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "multi-file-upload", "dependencies": [ "lucide-react", "react-dropzone", "class-variance-authority", "axios" ], "registryDependencies": [ "https://reusables.vercel.app/r/utils.json" ], "files": [ { "path": "registry/reusables/multi-file-upload.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva } from \"class-variance-authority\"\nimport { CheckCircle, File, FileText, UploadCloudIcon, X } from \"lucide-react\"\nimport { useDropzone, type DropzoneOptions } from \"react-dropzone\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport const formatFileSize = (bytes: number) => {\n if (typeof bytes !== \"number\") return \"0 B\"\n if (bytes === 0) return \"0 B\"\n const units = [\"B\", \"KB\", \"MB\", \"GB\"] as const\n const exponent = Math.min(\n Math.floor(Math.log(bytes) / Math.log(1024)),\n units.length - 1\n )\n const size = Number((bytes / Math.pow(1024, exponent)).toFixed(2))\n return `${size} ${units[exponent]}`\n}\n\nconst dropzoneVariants = cva(\n \"relative mx-auto flex max-w-3xl cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-all duration-300 ease-in-out\",\n {\n variants: {\n variant: {\n base: \"border-gray-300 bg-white hover:border-gray-400 dark:border-gray-600 dark:bg-transparent dark:hover:border-gray-500 dark:hover:bg-gray-800\",\n active: \"border-gray-400 dark:border-gray-500\",\n disabled:\n \"cursor-not-allowed border-gray-300 bg-gray-100 dark:border-gray-600 dark:bg-gray-700\",\n accept:\n \"border-green-500 bg-green-50 dark:border-green-400 dark:bg-green-900/20\",\n reject:\n \"border-red-500 bg-red-50 dark:border-red-400 dark:bg-red-900/20\",\n file: \"relative aspect-square h-full w-full rounded-md border-0 bg-slate-200 p-0 shadow-md dark:bg-slate-900\",\n },\n },\n defaultVariants: {\n variant: \"base\",\n },\n }\n)\n\nexport type FileState = {\n file: File | string\n key: string\n progress: \"PENDING\" | \"COMPLETE\" | \"ERROR\" | number\n}\n\ntype InputProps = {\n className?: string\n value?: FileState[]\n onChange?: (files: FileState[]) => void | Promise\n onFilesAdded?: (addedFiles: FileState[]) => void | Promise\n disabled?: boolean\n dropzoneOptions?: Omit\n isDirectUpload?: boolean\n onUpload?: (files: File[]) => void | Promise\n width?: string\n height?: string\n displayMode?: \"grid\" | \"list\"\n}\n\nconst ERROR_MESSAGES = {\n fileTooLarge(maxSize: number) {\n return `The file is too large. Max size is ${formatFileSize(maxSize)}.`\n },\n fileInvalidType() {\n return \"Invalid file type.\"\n },\n tooManyFiles(maxFiles: number) {\n return `You can only add ${maxFiles} file(s).`\n },\n fileNotSupported() {\n return \"The file is not supported.\"\n },\n}\n\nconst MultiFileUpload = React.forwardRef(\n (props, ref) => {\n const {\n dropzoneOptions,\n value,\n className,\n disabled,\n onChange,\n onFilesAdded,\n isDirectUpload,\n onUpload,\n width = \"100%\",\n height = \"300px\",\n displayMode = \"grid\",\n } = props\n\n const [customError, setCustomError] = React.useState()\n\n const fileUrls = React.useMemo(() => {\n if (value) {\n return value.map((fileState) => {\n if (typeof fileState.file === \"string\") {\n return fileState.file\n } else {\n return URL.createObjectURL(fileState.file)\n }\n })\n }\n return []\n }, [value])\n\n const handleDrop = async (acceptedFiles: File[]) => {\n const files = acceptedFiles\n setCustomError(undefined)\n\n if (\n dropzoneOptions?.maxFiles &&\n (value?.length ?? 0) + files.length > dropzoneOptions.maxFiles\n ) {\n setCustomError(ERROR_MESSAGES.tooManyFiles(dropzoneOptions.maxFiles))\n return\n }\n\n if (files) {\n const addedFiles = files.map((file) => ({\n file,\n key: Math.random().toString(36).slice(2),\n progress: \"PENDING\",\n }))\n\n if (isDirectUpload && onUpload) {\n await onUpload(files)\n }\n\n void onFilesAdded?.(addedFiles)\n void onChange?.([...(value ?? []), ...addedFiles])\n }\n }\n\n const {\n getRootProps,\n getInputProps,\n fileRejections,\n isFocused,\n isDragAccept,\n isDragReject,\n } = useDropzone({\n accept: {\n \"image/*\": [],\n \"application/pdf\": [],\n },\n disabled,\n onDrop: handleDrop,\n ...dropzoneOptions,\n })\n\n const variant = React.useMemo(() => {\n if (disabled) return \"disabled\"\n if (isDragReject) return \"reject\"\n if (isDragAccept) return \"accept\"\n if (isFocused) return \"active\"\n return \"base\"\n }, [isFocused, isDragAccept, isDragReject, disabled])\n\n const errorMessage = React.useMemo(() => {\n if (fileRejections[0]) {\n const { errors } = fileRejections[0]\n if (errors[0]?.code === \"file-too-large\") {\n return ERROR_MESSAGES.fileTooLarge(dropzoneOptions?.maxSize ?? 0)\n } else if (errors[0]?.code === \"file-invalid-type\") {\n return ERROR_MESSAGES.fileInvalidType()\n } else if (errors[0]?.code === \"too-many-files\") {\n return ERROR_MESSAGES.tooManyFiles(dropzoneOptions?.maxFiles ?? 0)\n } else {\n return ERROR_MESSAGES.fileNotSupported()\n }\n }\n return undefined\n }, [fileRejections, dropzoneOptions])\n\n const isPDF = (file: File | string) => {\n if (typeof file === \"string\") {\n return file.toLowerCase().endsWith(\".pdf\")\n }\n return file.type === \"application/pdf\"\n }\n\n return (\n
\n \n \n
\n \n
\n Drop your files here\n
\n
or
\n\n \n Browse\n \n\n
\n {dropzoneOptions?.maxFiles &&\n `Max ${dropzoneOptions.maxFiles} files, `}\n {dropzoneOptions?.maxSize &&\n `up to ${formatFileSize(dropzoneOptions.maxSize)}`}\n
\n
\n
\n\n {displayMode === \"grid\" ?\n
\n {value?.map(({ file, progress }, index) => (\n \n
\n {typeof file !== \"string\" ?\n isPDF(file) ?\n \n : \n\n : isPDF(file) ?\n \n : \n }\n
\n {/* Progress Bar */}\n {typeof progress === \"number\" && (\n
\n \n
\n )}\n\n {/* Remove File Icon */}\n {fileUrls[index] && !disabled && progress === \"PENDING\" && (\n {\n e.stopPropagation()\n void onChange?.(value.filter((_, i) => i !== index) ?? [])\n }}\n >\n
\n \n
\n
\n )}\n \n ))}\n \n :
\n {value?.map(({ file, progress, key }, index) => (\n \n
\n {/* Success Check Icon */}\n {progress === \"COMPLETE\" && (\n \n )}\n
\n
\n \n {typeof file === \"string\" ? file : file.name}\n \n \n {typeof file !== \"string\" && formatFileSize(file.size)}\n \n
\n
\n \n {typeof file !== \"string\" ?\n isPDF(file) ?\n \"application/pdf\"\n : `image/${\n file.name.split(\".\").pop()?.toLowerCase() ||\n \"unknown\"\n }`\n\n : isPDF(file) ?\n \"application/pdf\"\n : \"image/unknown\"}\n \n \n modified {new Date().toLocaleDateString()}\n
\n {typeof progress === \"number\" && (\n
\n \n
\n )}\n
\n
\n {!disabled && (\n {\n e.stopPropagation()\n onChange?.(value.filter((_, i) => i !== index))\n }}\n className=\"ml-4 rounded-lg p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-zinc-900/10 dark:hover:bg-white/10\"\n >\n \n \n )}\n
\n ))}\n \n }\n\n
\n {customError ?? errorMessage}\n
\n \n )\n }\n)\nMultiFileUpload.displayName = \"MultiFileUpload\"\n\nexport { MultiFileUpload }\n\nfunction CircleProgress({ progress }: { progress: number }) {\n const strokeWidth = 10\n const radius = 50\n const circumference = 2 * Math.PI * radius\n\n return (\n
\n \n \n \n \n
\n {Math.round(progress)}%\n
\n
\n )\n}\n\nfunction LinearProgress({ progress }: { progress: number }) {\n return (\n
\n \n
\n )\n}\n", "type": "registry:component", "target": "components/multi-file-upload.tsx" }, { "path": "registry/hooks/use-multiple-file-upload.tsx", "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport axios from \"axios\"\n\nexport type UploadStatus = \"PENDING\" | \"COMPLETE\" | \"ERROR\"\n\ninterface UploadProgress {\n key: string\n progress: number | UploadStatus\n}\n\ninterface UploadResult {\n url: string\n filename: string\n}\n\ninterface FileToUpload {\n key: string\n file: File\n}\n\ninterface UseMultipleFileUploadParams {\n onSuccess?: (urls: string[]) => void\n onError?: (error: string) => void\n}\n\nexport function useMultipleFileUpload({\n onSuccess,\n onError,\n}: UseMultipleFileUploadParams = {}) {\n const [uploadProgress, setUploadProgress] = useState([])\n const [uploadResults, setUploadResults] = useState([])\n const [isUploading, setIsUploading] = useState(false)\n const [error, setError] = useState(null)\n const abortControllersRef = useRef>(new Map())\n const imageUrlsRef = useRef([])\n\n useEffect(() => {\n return () => {\n // Cleanup URLs when component unmounts\n imageUrlsRef.current.forEach((url) => {\n if (url.startsWith(\"blob:\")) {\n URL.revokeObjectURL(url)\n }\n })\n }\n }, [])\n\n const uploadMultipleFiles = async (files: FileToUpload[]) => {\n setIsUploading(true)\n setError(null)\n\n // Initialize progress for all files\n setUploadProgress(\n files.map((file) => ({\n key: file.key,\n progress: \"PENDING\",\n }))\n )\n\n try {\n await Promise.all(\n files.map(async ({ key, file }) => {\n const abortController = new AbortController()\n abortControllersRef.current.set(key, abortController)\n\n try {\n const blobUrl = URL.createObjectURL(file)\n imageUrlsRef.current.push(blobUrl)\n\n const formData = new FormData()\n formData.append(\"file\", file)\n\n // TODO: Replace with your own API endpoint\n const response = await axios.post(\n `https://your-api-endpoint.com`,\n formData,\n {\n signal: abortController.signal,\n onUploadProgress: (progressEvent) => {\n const percentCompleted = Math.round(\n (progressEvent.loaded * 100) / progressEvent.total!\n )\n setUploadProgress((prev) =>\n prev.map((item) =>\n item.key === key ?\n { ...item, progress: percentCompleted }\n : item\n )\n )\n },\n }\n )\n // TODO: Replace with your own API response\n const uploadedUrl = response?.data?.secure_url\n\n // Add small delay to show complete state\n await new Promise((resolve) => setTimeout(resolve, 1000))\n\n setUploadProgress((prev) =>\n prev.map((item) =>\n item.key === key ? { ...item, progress: \"COMPLETE\" } : item\n )\n )\n\n setUploadResults((prev) => [\n ...prev,\n {\n url: uploadedUrl,\n filename: file.name,\n },\n ])\n\n // Cleanup blob URL after successful upload\n URL.revokeObjectURL(blobUrl)\n imageUrlsRef.current = imageUrlsRef.current.filter(\n (url) => url !== blobUrl\n )\n\n abortControllersRef.current.delete(key)\n } catch (error: any) {\n if (axios.isCancel(error)) {\n console.log(\"Upload cancelled for file:\", file.name)\n } else {\n const errorMessage =\n error.response?.data?.message || \"Upload failed\"\n setUploadProgress((prev) =>\n prev.map((item) =>\n item.key === key ? { ...item, progress: \"ERROR\" } : item\n )\n )\n onError?.(errorMessage)\n setError(errorMessage)\n }\n }\n })\n )\n\n const successfulUploads = uploadResults.map((result) => result.url)\n if (successfulUploads.length > 0) {\n onSuccess?.(successfulUploads)\n }\n } catch (error) {\n console.error(\"Error in multiple file upload:\", error)\n } finally {\n setIsUploading(false)\n }\n }\n\n const cancelAllUploads = () => {\n abortControllersRef.current.forEach((controller) => {\n controller.abort()\n })\n abortControllersRef.current.clear()\n setUploadProgress([])\n setUploadResults([])\n setIsUploading(false)\n setError(null)\n }\n\n const resetUploads = () => {\n setUploadProgress([])\n setUploadResults([])\n setIsUploading(false)\n setError(null)\n }\n\n return {\n uploadMultipleFiles,\n uploadProgress,\n uploadResults,\n isUploading,\n error,\n cancelAllUploads,\n resetUploads,\n }\n}\n", "type": "registry:hook", "target": "hooks/use-multiple-file-upload.tsx" } ], "type": "registry:component" }