{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comp-126",
  "type": "registry:component",
  "registryDependencies": [
    "https://coss.com/origin/r/button.json"
  ],
  "files": [
    {
      "path": "registry/default/components/comp-126.tsx",
      "content": "\"use client\";\n\nimport { CircleUserRoundIcon, XIcon } from \"lucide-react\";\n\nimport { useFileUpload } from \"@/registry/default/hooks/use-file-upload\";\nimport { Button } from \"@/registry/default/ui/button\";\n\nexport default function Component() {\n  const [{ files }, { removeFile, openFileDialog, getInputProps }] =\n    useFileUpload({\n      accept: \"image/*\",\n    });\n\n  const previewUrl = files[0]?.preview || null;\n  const fileName = files[0]?.file.name || null;\n\n  return (\n    <div className=\"flex flex-col items-center gap-2\">\n      <div className=\"relative inline-flex\">\n        <Button\n          aria-label={previewUrl ? \"Change image\" : \"Upload image\"}\n          className=\"relative size-16 overflow-hidden p-0 shadow-none\"\n          onClick={openFileDialog}\n          variant=\"outline\"\n        >\n          {previewUrl ? (\n            <img\n              alt=\"Upload preview\"\n              className=\"size-full object-cover\"\n              height={64}\n              src={previewUrl}\n              style={{ objectFit: \"cover\" }}\n              width={64}\n            />\n          ) : (\n            <div aria-hidden=\"true\">\n              <CircleUserRoundIcon className=\"size-4 opacity-60\" />\n            </div>\n          )}\n        </Button>\n        {previewUrl && (\n          <Button\n            aria-label=\"Remove image\"\n            className=\"-top-2 -right-2 absolute size-6 rounded-full border-2 border-background shadow-none focus-visible:border-background\"\n            onClick={() => removeFile(files[0]?.id)}\n            size=\"icon\"\n          >\n            <XIcon className=\"size-3.5\" />\n          </Button>\n        )}\n        <input\n          {...getInputProps()}\n          aria-label=\"Upload image file\"\n          className=\"sr-only\"\n          tabIndex={-1}\n        />\n      </div>\n      {fileName && <p className=\"text-muted-foreground text-xs\">{fileName}</p>}\n      <p\n        aria-live=\"polite\"\n        className=\"mt-2 text-muted-foreground text-xs\"\n        role=\"region\"\n      >\n        Avatar upload button\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/default/hooks/use-file-upload.ts",
      "content": "\"use client\";\n\nimport {\n  type ChangeEvent,\n  type DragEvent,\n  type InputHTMLAttributes,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type FileMetadata = {\n  name: string;\n  size: number;\n  type: string;\n  url: string;\n  id: string;\n};\n\nexport type FileWithPreview = {\n  file: File | FileMetadata;\n  id: string;\n  preview?: string;\n};\n\nexport type FileUploadOptions = {\n  maxFiles?: number; // Only used when multiple is true, defaults to Infinity\n  maxSize?: number; // in bytes\n  accept?: string;\n  multiple?: boolean; // Defaults to false\n  initialFiles?: FileMetadata[];\n  onFilesChange?: (files: FileWithPreview[]) => void; // Callback when files change\n  onFilesAdded?: (addedFiles: FileWithPreview[]) => void; // Callback when new files are added\n};\n\nexport type FileUploadState = {\n  files: FileWithPreview[];\n  isDragging: boolean;\n  errors: string[];\n};\n\nexport type FileUploadActions = {\n  addFiles: (files: FileList | File[]) => void;\n  removeFile: (id: string) => void;\n  clearFiles: () => void;\n  clearErrors: () => void;\n  handleDragEnter: (e: DragEvent<HTMLElement>) => void;\n  handleDragLeave: (e: DragEvent<HTMLElement>) => void;\n  handleDragOver: (e: DragEvent<HTMLElement>) => void;\n  handleDrop: (e: DragEvent<HTMLElement>) => void;\n  handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void;\n  openFileDialog: () => void;\n  getInputProps: (\n    props?: InputHTMLAttributes<HTMLInputElement>,\n  ) => InputHTMLAttributes<HTMLInputElement> & {\n    // Use `any` here to avoid cross-React ref type conflicts across packages\n    // biome-ignore lint/suspicious/noExplicitAny: intentional\n    ref: any;\n  };\n};\n\nexport const useFileUpload = (\n  options: FileUploadOptions = {},\n): [FileUploadState, FileUploadActions] => {\n  const {\n    maxFiles = Number.POSITIVE_INFINITY,\n    maxSize = Number.POSITIVE_INFINITY,\n    accept = \"*\",\n    multiple = false,\n    initialFiles = [],\n    onFilesChange,\n    onFilesAdded,\n  } = options;\n\n  const [state, setState] = useState<FileUploadState>({\n    errors: [],\n    files: initialFiles.map((file) => ({\n      file,\n      id: file.id,\n      preview: file.url,\n    })),\n    isDragging: false,\n  });\n\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const validateFile = useCallback(\n    (file: File | FileMetadata): string | null => {\n      if (file instanceof File) {\n        if (file.size > maxSize) {\n          return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`;\n        }\n      } else {\n        if (file.size > maxSize) {\n          return `File \"${file.name}\" exceeds the maximum size of ${formatBytes(maxSize)}.`;\n        }\n      }\n\n      if (accept !== \"*\") {\n        const acceptedTypes = accept.split(\",\").map((type) => type.trim());\n        const fileType = file instanceof File ? file.type || \"\" : file.type;\n        const fileExtension = `.${file instanceof File ? file.name.split(\".\").pop() : file.name.split(\".\").pop()}`;\n\n        const isAccepted = acceptedTypes.some((type) => {\n          if (type.startsWith(\".\")) {\n            return fileExtension.toLowerCase() === type.toLowerCase();\n          }\n          if (type.endsWith(\"/*\")) {\n            const baseType = type.split(\"/\")[0];\n            return fileType.startsWith(`${baseType}/`);\n          }\n          return fileType === type;\n        });\n\n        if (!isAccepted) {\n          return `File \"${file instanceof File ? file.name : file.name}\" is not an accepted file type.`;\n        }\n      }\n\n      return null;\n    },\n    [accept, maxSize],\n  );\n\n  const createPreview = useCallback(\n    (file: File | FileMetadata): string | undefined => {\n      if (file instanceof File) {\n        return URL.createObjectURL(file);\n      }\n      return file.url;\n    },\n    [],\n  );\n\n  const generateUniqueId = useCallback((file: File | FileMetadata): string => {\n    if (file instanceof File) {\n      return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;\n    }\n    return file.id;\n  }, []);\n\n  const clearFiles = useCallback(() => {\n    setState((prev) => {\n      // Clean up object URLs\n      for (const file of prev.files ?? []) {\n        if (\n          file.preview &&\n          file.file instanceof File &&\n          file.file.type.startsWith(\"image/\")\n        ) {\n          URL.revokeObjectURL(file.preview);\n        }\n      }\n\n      if (inputRef.current) {\n        inputRef.current.value = \"\";\n      }\n\n      const newState = {\n        ...prev,\n        errors: [],\n        files: [],\n      };\n\n      onFilesChange?.(newState.files);\n      return newState;\n    });\n  }, [onFilesChange]);\n\n  const addFiles = useCallback(\n    (newFiles: FileList | File[]) => {\n      if (!newFiles || newFiles.length === 0) return;\n\n      const newFilesArray = Array.from(newFiles);\n      const errors: string[] = [];\n\n      // Clear existing errors when new files are uploaded\n      setState((prev) => ({ ...prev, errors: [] }));\n\n      // In single file mode, clear existing files first\n      if (!multiple) {\n        clearFiles();\n      }\n\n      // Check if adding these files would exceed maxFiles (only in multiple mode)\n      if (\n        multiple &&\n        maxFiles !== Number.POSITIVE_INFINITY &&\n        state.files.length + newFilesArray.length > maxFiles\n      ) {\n        errors.push(`You can only upload a maximum of ${maxFiles} files.`);\n        setState((prev) => ({ ...prev, errors }));\n        return;\n      }\n\n      const validFiles: FileWithPreview[] = [];\n\n      for (const file of newFilesArray) {\n        if (multiple) {\n          const isDuplicate = state.files.some(\n            (existingFile) =>\n              existingFile.file.name === file.name &&\n              existingFile.file.size === file.size,\n          );\n\n          if (isDuplicate) {\n            continue;\n          }\n        }\n\n        if (file.size > maxSize) {\n          errors.push(\n            multiple\n              ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`\n              : `File exceeds the maximum size of ${formatBytes(maxSize)}.`,\n          );\n          continue;\n        }\n\n        const error = validateFile(file);\n\n        if (error) {\n          errors.push(error);\n          continue;\n        }\n\n        validFiles.push({\n          file,\n          id: generateUniqueId(file),\n          preview: createPreview(file),\n        });\n      }\n\n      // Only update state if we have valid files to add\n      if (validFiles.length > 0) {\n        // Call the onFilesAdded callback with the newly added valid files\n        onFilesAdded?.(validFiles);\n\n        setState((prev) => {\n          const newFiles = !multiple\n            ? validFiles\n            : [...prev.files, ...validFiles];\n          onFilesChange?.(newFiles);\n          return {\n            ...prev,\n            errors,\n            files: newFiles,\n          };\n        });\n      } else if (errors.length > 0) {\n        setState((prev) => ({\n          ...prev,\n          errors,\n        }));\n      }\n\n      // Reset input value after handling files\n      if (inputRef.current) {\n        inputRef.current.value = \"\";\n      }\n    },\n    [\n      state.files,\n      maxFiles,\n      multiple,\n      maxSize,\n      validateFile,\n      createPreview,\n      generateUniqueId,\n      clearFiles,\n      onFilesChange,\n      onFilesAdded,\n    ],\n  );\n\n  const removeFile = useCallback(\n    (id: string) => {\n      setState((prev) => {\n        const fileToRemove = prev.files.find((file) => file.id === id);\n        if (\n          fileToRemove?.preview &&\n          fileToRemove.file instanceof File &&\n          fileToRemove.file.type.startsWith(\"image/\")\n        ) {\n          URL.revokeObjectURL(fileToRemove.preview);\n        }\n\n        const newFiles = prev.files.filter((file) => file.id !== id);\n        onFilesChange?.(newFiles);\n\n        return {\n          ...prev,\n          errors: [],\n          files: newFiles,\n        };\n      });\n    },\n    [onFilesChange],\n  );\n\n  const clearErrors = useCallback(() => {\n    setState((prev) => ({\n      ...prev,\n      errors: [],\n    }));\n  }, []);\n\n  const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setState((prev) => ({ ...prev, isDragging: true }));\n  }, []);\n\n  const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {\n    e.preventDefault();\n    e.stopPropagation();\n\n    if (e.currentTarget.contains(e.relatedTarget as Node)) {\n      return;\n    }\n\n    setState((prev) => ({ ...prev, isDragging: false }));\n  }, []);\n\n  const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {\n    e.preventDefault();\n    e.stopPropagation();\n  }, []);\n\n  const handleDrop = useCallback(\n    (e: DragEvent<HTMLElement>) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setState((prev) => ({ ...prev, isDragging: false }));\n\n      // Don't process files if the input is disabled\n      if (inputRef.current?.disabled) {\n        return;\n      }\n\n      if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n        // In single file mode, only use the first file\n        if (!multiple) {\n          const file = e.dataTransfer.files[0];\n          addFiles([file]);\n        } else {\n          addFiles(e.dataTransfer.files);\n        }\n      }\n    },\n    [addFiles, multiple],\n  );\n\n  const handleFileChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => {\n      if (e.target.files && e.target.files.length > 0) {\n        addFiles(e.target.files);\n      }\n    },\n    [addFiles],\n  );\n\n  const openFileDialog = useCallback(() => {\n    if (inputRef.current) {\n      inputRef.current.click();\n    }\n  }, []);\n\n  const getInputProps = useCallback(\n    (props: InputHTMLAttributes<HTMLInputElement> = {}) => {\n      return {\n        ...props,\n        accept: props.accept || accept,\n        multiple: props.multiple !== undefined ? props.multiple : multiple,\n        onChange: handleFileChange,\n        // Cast to `any` to prevent mismatched React ref type errors across workspaces\n        // biome-ignore lint/suspicious/noExplicitAny: Intentional\n        ref: inputRef as any,\n        type: \"file\" as const,\n      };\n    },\n    [accept, multiple, handleFileChange],\n  );\n\n  return [\n    state,\n    {\n      addFiles,\n      clearErrors,\n      clearFiles,\n      getInputProps,\n      handleDragEnter,\n      handleDragLeave,\n      handleDragOver,\n      handleDrop,\n      handleFileChange,\n      openFileDialog,\n      removeFile,\n    },\n  ];\n};\n\n// Helper function to format bytes to human-readable format\nexport const formatBytes = (bytes: number, decimals = 2): string => {\n  if (bytes === 0) return \"0 Bytes\";\n\n  const k = 1024;\n  const dm = decimals < 0 ? 0 : decimals;\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"];\n\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n  return Number.parseFloat((bytes / k ** i).toFixed(dm)) + sizes[i];\n};\n",
      "type": "registry:hook"
    }
  ],
  "meta": {
    "style": 2,
    "tags": [
      "button",
      "upload",
      "user",
      "avatar",
      "profile",
      "image"
    ]
  }
}