{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "file-upload-zone", "title": "File Upload Zone", "description": "Drag-and-drop file upload zone with file list and remove actions.", "dependencies": ["lucide-react"], "registryDependencies": ["badge", "button", "utils"], "files": [ { "path": "src/patterns/file-upload-zone/file-upload-zone.tsx", "content": "import * as React from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Upload, X } from \"lucide-react\";\nimport { cn } from \"../../lib/utils\";\nimport { Badge } from \"../../primitives/badge\";\nimport { Button } from \"../../primitives/button\";\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction formatFileSize(bytes: number): string {\n if (bytes === 0) return \"0 B\";\n const units = [\"B\", \"KB\", \"MB\", \"GB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(1024));\n const size = bytes / Math.pow(1024, i);\n return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;\n}\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface FileUploadZoneProps {\n /** Currently selected file, or null when no file is selected */\n file: File | null;\n /** Called when the user selects or removes a file */\n onFileChange: (file: File | null) => void;\n /**\n * Optional MIME type accept filter, e.g. `{ \"application/pdf\": [\".pdf\"] }`.\n * Passed to the hidden `` accept attribute (only keys are used).\n */\n accept?: Record;\n}\n\n// ---------------------------------------------------------------------------\n// Main export\n// ---------------------------------------------------------------------------\n\nexport function FileUploadZone({ file, onFileChange, accept }: Readonly) {\n const inputRef = useRef(null);\n const [isDragActive, setIsDragActive] = useState(false);\n const [previewUrl, setPreviewUrl] = useState(null);\n\n // Build the accept string for the hidden file input\n const acceptString = accept ? Object.keys(accept).join(\",\") : undefined;\n\n // Image preview URL lifecycle\n useEffect(() => {\n if (!file?.type.startsWith(\"image/\")) {\n setPreviewUrl(null);\n return;\n }\n const url = URL.createObjectURL(file);\n setPreviewUrl(url);\n return () => URL.revokeObjectURL(url);\n }, [file]);\n\n const handleDragOver = useCallback((e: React.DragEvent) => {\n e.preventDefault();\n setIsDragActive(true);\n }, []);\n\n const handleDragLeave = useCallback(() => {\n setIsDragActive(false);\n }, []);\n\n const handleDrop = useCallback(\n (e: React.DragEvent) => {\n e.preventDefault();\n setIsDragActive(false);\n const dropped = e.dataTransfer.files[0];\n if (dropped) onFileChange(dropped);\n },\n [onFileChange],\n );\n\n const handleInputChange = useCallback(\n (e: React.ChangeEvent) => {\n const selected = e.target.files?.[0];\n if (selected) onFileChange(selected);\n // Reset the input value so the same file can be re-selected\n e.target.value = \"\";\n },\n [onFileChange],\n );\n\n // -------------------------------------------------------------------------\n // File selected view\n // -------------------------------------------------------------------------\n if (file) {\n const isImage = file.type.startsWith(\"image/\");\n\n return (\n
\n {previewUrl && isImage ? (\n \"Preview\"\n ) : (\n \n )}\n
\n

{file.name}

\n
\n {formatFileSize(file.size)}\n {file.type && (\n \n {file.type}\n \n )}\n
\n
\n \n
\n );\n }\n\n // -------------------------------------------------------------------------\n // Drop-zone view\n // -------------------------------------------------------------------------\n return (\n <>\n \n inputRef.current?.click()}\n className={cn(\n \"flex w-full cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed p-8 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n isDragActive\n ? \"border-primary bg-primary/5\"\n : \"border-muted-foreground/25 hover:border-primary/50\",\n )}\n >\n \n

\n {isDragActive ? \"Drop the file here\" : \"Drag & drop a file, or click to select\"}\n

\n \n \n );\n}\n", "type": "registry:ui" }, { "path": "src/patterns/file-upload-zone/index.ts", "content": "export { FileUploadZone } from \"./file-upload-zone\";\nexport type { FileUploadZoneProps } from \"./file-upload-zone\";\n", "type": "registry:ui" } ], "type": "registry:ui" }