{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "single-image-upload", "dependencies": [ "lucide-react", "react-dropzone", "class-variance-authority", "axios" ], "registryDependencies": [ "https://reusables.vercel.app/r/utils.json" ], "files": [ { "path": "registry/reusables/single-image-upload.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva } from \"class-variance-authority\"\nimport { UploadCloud, 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 disabled:cursor-not-allowed\",\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 image: \"border-transparent bg-transparent\",\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 },\n },\n defaultVariants: {\n variant: \"base\",\n },\n }\n)\n\ntype ProgressType = \"linear\" | \"circular\"\n\ntype InputProps = {\n width?: string\n height?: string\n className?: string\n value?: File | string\n onChange?: (file?: File) => void | Promise\n disabled?: boolean\n dropzoneOptions?: Omit\n directUpload?: boolean\n progress?: number\n progressType?: ProgressType\n}\n\nconst ERROR_MESSAGES = {\n fileTooLarge: (maxSize: number) =>\n `File too large. Max size: ${formatFileSize(maxSize)}.`,\n fileInvalidType: () => \"Invalid file type.\",\n tooManyFiles: (maxFiles: number) => `Max ${maxFiles} file(s) allowed.`,\n fileNotSupported: () => \"File type not supported.\",\n}\n\nexport const SingleImageDropzone = React.forwardRef<\n HTMLInputElement,\n InputProps\n>(\n (\n {\n dropzoneOptions,\n width = \"100%\",\n height = \"300px\",\n value,\n className,\n disabled,\n onChange,\n directUpload = true,\n progress,\n progressType = \"linear\",\n },\n ref\n ) => {\n const [localValue, setLocalValue] = React.useState(\n undefined\n )\n\n // Sync localValue with form value\n React.useEffect(() => {\n if (!value) {\n setLocalValue(undefined)\n }\n }, [value])\n\n const imageUrl = React.useMemo(() => {\n const fileToUse = directUpload ? localValue : value\n if (typeof fileToUse === \"string\") return fileToUse\n if (fileToUse instanceof File) return URL.createObjectURL(fileToUse)\n return null\n }, [value, localValue, directUpload])\n\n const onDrop = React.useCallback(\n (acceptedFiles: File[]) => {\n const file = acceptedFiles[0]\n if (!file) return\n\n setLocalValue(file)\n void onChange?.(file)\n },\n [onChange]\n )\n\n const {\n getRootProps,\n getInputProps,\n acceptedFiles,\n fileRejections,\n isFocused,\n isDragAccept,\n isDragReject,\n } = useDropzone({\n accept: { \"image/*\": [] },\n multiple: false,\n disabled,\n onDrop,\n ...dropzoneOptions,\n })\n\n const variant = React.useMemo(() => {\n if (imageUrl) return \"image\"\n if (disabled) return \"disabled\"\n if (isDragReject || fileRejections.length) return \"reject\"\n if (isDragAccept) return \"accept\"\n if (isFocused) return \"active\"\n return \"base\"\n }, [\n isFocused,\n imageUrl,\n fileRejections,\n isDragAccept,\n isDragReject,\n disabled,\n ])\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 handleClear = (e: React.MouseEvent) => {\n e.stopPropagation()\n setLocalValue(undefined)\n void onChange?.(undefined)\n }\n\n return (\n
\n \n \n\n {imageUrl ?\n
\n \n\n \n {progress ?\n <>\n {progressType === \"linear\" && (\n \n Uploading...\n \n )}\n \n \n : \n Replace image\n \n }\n
\n
\n :
\n
\n \n
\n
\n Drag & drop your image here\n
\n
or
\n \n Browse files\n \n
\n {dropzoneOptions?.maxSize &&\n `Up to ${formatFileSize(dropzoneOptions.maxSize)}`}\n
\n
\n }\n\n {imageUrl && !disabled && (\n \n \n \n )}\n \n\n {errorMessage && (\n
\n {errorMessage}\n
\n )}\n \n )\n }\n)\n\nSingleImageDropzone.displayName = \"SingleImageDropzone\"\n\nconst ProgressIndicator = ({\n progress = 0,\n type = \"linear\",\n}: {\n progress: number\n type?: ProgressType\n}) => {\n if (type === \"circular\") {\n return (\n
\n \n \n \n \n
\n \n {Math.round(progress)}%\n \n
\n
\n )\n }\n\n return (\n
\n \n
\n )\n}\n", "type": "registry:component", "target": "components/single-image-upload.tsx" }, { "path": "registry/hooks/use-file-upload.tsx", "content": "\"use client\"\n\nimport { useRef, useState } from \"react\"\nimport axios from \"axios\"\n\ninterface UseFileUploadParams {\n onSuccess?: (url: string) => void\n onError?: (error: string) => void\n}\n\nconst useFileUpload = ({ onSuccess, onError }: UseFileUploadParams = {}) => {\n const [selectedFile, setSelectedFile] = useState(null)\n const [isUploading, setIsUploading] = useState(false)\n const [progress, setProgress] = useState(0)\n const [error, setError] = useState(null)\n const abortControllerRef = useRef(null)\n const [data, setData] = useState(null)\n\n const handleFileUpload = async (file: File | null) => {\n if (!file) {\n throw new Error(\"No file selected\")\n }\n\n setSelectedFile(file)\n setIsUploading(true)\n setError(null)\n\n abortControllerRef.current = new AbortController()\n\n try {\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: abortControllerRef.current.signal,\n onUploadProgress: (progressEvent) => {\n const percentCompleted = Math.round(\n (progressEvent.loaded * 100) / progressEvent.total!\n )\n setProgress(percentCompleted)\n },\n }\n )\n // TODO: Replace with your own API response\n const uploadedUrl = response?.data?.secure_url\n\n setData(uploadedUrl)\n onSuccess?.(uploadedUrl)\n setIsUploading(false)\n setProgress(0)\n abortControllerRef.current = null\n } catch (error: any) {\n if (axios.isCancel(error)) {\n const cancelMessage = \"Upload cancelled\"\n console.log(cancelMessage)\n } else {\n const errorMessage = error.response?.data?.message || \"Upload failed\"\n setError(errorMessage)\n onError?.(errorMessage)\n }\n setIsUploading(false)\n setSelectedFile(null)\n setProgress(0)\n setData(null)\n abortControllerRef.current = null\n }\n }\n\n const cancelUpload = () => {\n if (abortControllerRef.current) {\n abortControllerRef.current.abort()\n }\n setSelectedFile(null)\n setIsUploading(false)\n setProgress(0)\n setError(null)\n setData(null)\n }\n\n return {\n selectedFile,\n isUploading,\n progress,\n error,\n handleFileUpload,\n cancelUpload,\n data,\n }\n}\n\nexport default useFileUpload\n", "type": "registry:hook", "target": "hooks/use-file-upload.tsx" } ], "type": "registry:component" }