{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone",
  "title": "Dropzone",
  "description": "Drag-and-drop file upload with validation, progress tracking, and file list",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "@boldkit/utils",
    "@boldkit/progress",
    "@boldkit/spinner"
  ],
  "files": [
    {
      "path": "registry/default/ui/dropzone.tsx",
      "content": "/* eslint-disable react-refresh/only-export-components */\nimport * as React from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@/lib/utils'\nimport { Progress } from '@/components/ui/progress'\nimport { Spinner } from '@/components/ui/spinner'\nimport { Upload, X, File, Image, FileText, FileCode, FileAudio, FileVideo } from 'lucide-react'\n\n// Types\nexport interface FileRejection {\n  file: File\n  errors: Array<{ code: string; message: string }>\n}\n\nexport interface DropzoneState {\n  isDragging: boolean\n  isDisabled: boolean\n  acceptedFiles: File[]\n  rejectedFiles: FileRejection[]\n  reset: () => void\n}\n\n// Variants\nconst dropzoneVariants = cva(\n  'relative flex flex-col items-center justify-center border-3 border-dashed border-foreground transition duration-200 cursor-pointer',\n  {\n    variants: {\n      state: {\n        idle: 'bg-background hover:bg-muted/30 shadow-[4px_4px_0px_hsl(var(--shadow-color))] hover:shadow-[6px_6px_0px_hsl(var(--shadow-color))] hover:translate-x-[-2px] hover:translate-y-[-2px]',\n        dragging: 'border-solid border-primary bg-primary/10 scale-[1.02] shadow-[8px_8px_0px_hsl(var(--primary))]',\n        disabled: 'opacity-50 cursor-not-allowed shadow-none',\n      },\n      variant: {\n        default: 'p-8',\n        compact: 'p-6',\n        minimal: 'p-3 border-2',\n      },\n    },\n    defaultVariants: {\n      state: 'idle',\n      variant: 'default',\n    },\n  }\n)\n\n// Dropzone Props\nexport interface DropzoneProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>,\n    VariantProps<typeof dropzoneVariants> {\n  onFilesAccepted: (files: File[]) => void\n  onFilesRejected?: (files: FileRejection[]) => void\n  accept?: Record<string, string[]>\n  maxSize?: number\n  maxFiles?: number\n  disabled?: boolean\n  children?: React.ReactNode | ((state: DropzoneState) => React.ReactNode)\n}\n\nconst Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(\n  (\n    {\n      onFilesAccepted,\n      onFilesRejected,\n      accept,\n      maxSize = 10 * 1024 * 1024, // 10MB default\n      maxFiles = 10,\n      disabled = false,\n      variant,\n      className,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const [isDragging, setIsDragging] = React.useState(false)\n    const [isFocused, setIsFocused] = React.useState(false)\n    const [acceptedFiles, setAcceptedFiles] = React.useState<File[]>([])\n    const [rejectedFiles, setRejectedFiles] = React.useState<FileRejection[]>([])\n    const inputRef = React.useRef<HTMLInputElement>(null)\n\n    const reset = React.useCallback(() => {\n      setAcceptedFiles([])\n      setRejectedFiles([])\n      if (inputRef.current) inputRef.current.value = ''\n    }, [])\n\n    const state: DropzoneState = {\n      isDragging,\n      isDisabled: disabled,\n      acceptedFiles,\n      rejectedFiles,\n      reset,\n    }\n\n    const stateVariant = disabled ? 'disabled' : isDragging ? 'dragging' : 'idle'\n\n    // Validate file\n    const validateFile = (file: File): FileRejection | null => {\n      const errors: Array<{ code: string; message: string }> = []\n\n      // Check file size\n      if (file.size > maxSize) {\n        errors.push({\n          code: 'file-too-large',\n          message: `File is larger than ${formatBytes(maxSize)}`,\n        })\n      }\n\n      // Check file type\n      if (accept) {\n        const acceptedTypes = Object.entries(accept).flatMap(([mimeType, extensions]) => {\n          return [mimeType, ...extensions]\n        })\n\n        const fileType = file.type\n        const fileExtension = `.${file.name.split('.').pop()?.toLowerCase()}`\n\n        const isAccepted = acceptedTypes.some((type) => {\n          if (type.startsWith('.')) {\n            return fileExtension === type.toLowerCase()\n          }\n          if (type.endsWith('/*')) {\n            return fileType.startsWith(type.replace('/*', '/'))\n          }\n          return fileType === type\n        })\n\n        if (!isAccepted) {\n          errors.push({\n            code: 'file-invalid-type',\n            message: 'File type not accepted',\n          })\n        }\n      }\n\n      return errors.length > 0 ? { file, errors } : null\n    }\n\n    // Process files\n    const processFiles = (fileList: FileList | null) => {\n      if (!fileList || disabled) return\n\n      const allFiles = Array.from(fileList)\n      const accepted: File[] = []\n      const rejected: FileRejection[] = []\n\n      allFiles.forEach((file) => {\n        const rejection = validateFile(file)\n        if (rejection) {\n          rejected.push(rejection)\n        } else if (accepted.length >= maxFiles) {\n          rejected.push({\n            file,\n            errors: [{ code: 'too-many-files', message: `Too many files. Maximum is ${maxFiles}.` }],\n          })\n        } else {\n          accepted.push(file)\n        }\n      })\n\n      setAcceptedFiles(accepted)\n      setRejectedFiles(rejected)\n\n      if (accepted.length > 0) {\n        onFilesAccepted(accepted)\n      }\n      if (rejected.length > 0) {\n        onFilesRejected?.(rejected)\n      }\n    }\n\n    // Event handlers\n    const handleDragEnter = (e: React.DragEvent) => {\n      e.preventDefault()\n      e.stopPropagation()\n      if (!disabled) {\n        setIsDragging(true)\n      }\n    }\n\n    const handleDragLeave = (e: React.DragEvent) => {\n      e.preventDefault()\n      e.stopPropagation()\n      // dragleave also fires when the cursor moves onto a child element; only\n      // clear the highlight when the pointer actually leaves the dropzone.\n      if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget as Node)) {\n        return\n      }\n      setIsDragging(false)\n    }\n\n    const handleDragOver = (e: React.DragEvent) => {\n      e.preventDefault()\n      e.stopPropagation()\n    }\n\n    const handleDrop = (e: React.DragEvent) => {\n      e.preventDefault()\n      e.stopPropagation()\n      setIsDragging(false)\n      processFiles(e.dataTransfer.files)\n    }\n\n    const handleClick = () => {\n      if (!disabled) {\n        inputRef.current?.click()\n      }\n    }\n\n    const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if (!disabled && (e.key === 'Enter' || e.key === ' ')) {\n        e.preventDefault()\n        inputRef.current?.click()\n      }\n    }\n\n    const handleFocus = () => setIsFocused(true)\n    const handleBlur = () => setIsFocused(false)\n\n    const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n      processFiles(e.target.files)\n      e.target.value = ''\n    }\n\n    // Build accept string for input\n    const acceptString = accept\n      ? Object.entries(accept)\n          .flatMap(([mimeType, extensions]) => [mimeType, ...extensions])\n          .join(',')\n      : undefined\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          dropzoneVariants({ state: stateVariant, variant }),\n          isFocused && !disabled && 'outline outline-2 outline-offset-2 outline-primary',\n          className\n        )}\n        onDragEnter={handleDragEnter}\n        onDragLeave={handleDragLeave}\n        onDragOver={handleDragOver}\n        onDrop={handleDrop}\n        onClick={handleClick}\n        onKeyDown={handleKeyDown}\n        onFocus={handleFocus}\n        onBlur={handleBlur}\n        role=\"button\"\n        tabIndex={disabled ? -1 : 0}\n        aria-disabled={disabled}\n        aria-label=\"File upload area\"\n        {...props}\n      >\n        <input\n          ref={inputRef}\n          type=\"file\"\n          accept={acceptString}\n          multiple={maxFiles > 1}\n          disabled={disabled}\n          onChange={handleInputChange}\n          className=\"hidden\"\n        />\n\n        {typeof children === 'function' ? (\n          children(state)\n        ) : children ? (\n          children\n        ) : (\n          <DefaultDropzoneContent isDragging={isDragging} variant={variant} />\n        )}\n      </div>\n    )\n  }\n)\nDropzone.displayName = 'Dropzone'\n\n// Default content\nfunction DefaultDropzoneContent({\n  isDragging,\n  variant,\n}: {\n  isDragging: boolean\n  variant: DropzoneProps['variant']\n}) {\n  return (\n    <div className=\"flex flex-col items-center gap-3 text-center\">\n      <div\n        className={cn(\n          'flex items-center justify-center w-16 h-16 border-3 border-foreground bg-muted transition duration-200',\n          isDragging && 'bg-primary border-primary shadow-[4px_4px_0px_hsl(var(--foreground))] -translate-x-1 -translate-y-1'\n        )}\n      >\n        <Upload\n          className={cn(\n            'h-8 w-8 transition duration-200',\n            isDragging ? 'text-primary-foreground animate-bounce' : 'text-foreground'\n          )}\n        />\n      </div>\n      {variant !== 'minimal' && (\n        <>\n          <p className=\"font-black uppercase tracking-wide text-lg\">\n            {isDragging ? 'Drop files here' : 'Drag & drop files'}\n          </p>\n          <p className=\"text-sm text-muted-foreground font-bold\">\n            or click to browse\n          </p>\n        </>\n      )}\n    </div>\n  )\n}\n\n// File List Component\nexport interface FileListProps extends React.HTMLAttributes<HTMLDivElement> {\n  files: Array<{\n    file: File\n    progress?: number\n    error?: string\n    uploading?: boolean\n  }>\n  onRemove?: (file: File) => void\n}\n\nconst FileList = React.forwardRef<HTMLDivElement, FileListProps>(\n  ({ files, onRemove, className, ...props }, ref) => {\n    if (files.length === 0) return null\n\n    return (\n      <div\n        ref={ref}\n        className={cn('space-y-2 mt-4', className)}\n        {...props}\n      >\n        {files.map((item, index) => (\n          <FileListItem\n            key={`${item.file.name}-${index}`}\n            file={item.file}\n            progress={item.progress}\n            error={item.error}\n            uploading={item.uploading}\n            onRemove={onRemove ? () => onRemove(item.file) : undefined}\n          />\n        ))}\n      </div>\n    )\n  }\n)\nFileList.displayName = 'FileList'\n\n// File List Item\ninterface FileListItemProps {\n  file: File\n  progress?: number\n  error?: string\n  uploading?: boolean\n  onRemove?: () => void\n}\n\nfunction FileListItem({ file, progress, error, uploading, onRemove }: FileListItemProps) {\n  const Icon = getFileIcon(file.type)\n\n  return (\n    <div\n      className={cn(\n        'flex items-center gap-3 p-3 border-3 border-foreground bg-background shadow-[3px_3px_0px_hsl(var(--shadow-color))]',\n        error && 'border-destructive bg-destructive/10 shadow-[3px_3px_0px_hsl(var(--destructive))]'\n      )}\n    >\n      <div className=\"flex items-center justify-center w-10 h-10 bg-muted border-3 border-foreground\">\n        <Icon className=\"h-5 w-5\" />\n      </div>\n\n      <div className=\"flex-1 min-w-0\">\n        <p className=\"font-bold text-sm truncate\">{file.name}</p>\n        <p className=\"text-xs text-muted-foreground\">{formatBytes(file.size)}</p>\n        {error && <p className=\"text-xs text-destructive font-bold\">{error}</p>}\n        {uploading && progress !== undefined && (\n          <Progress value={progress} className=\"h-2 mt-1\" />\n        )}\n      </div>\n\n      {uploading ? (\n        <Spinner size=\"sm\" />\n      ) : onRemove ? (\n        <button\n          type=\"button\"\n          onClick={(e) => {\n            e.stopPropagation()\n            onRemove()\n          }}\n          className=\"flex items-center justify-center w-8 h-8 border-3 border-foreground bg-background hover:bg-destructive hover:text-destructive-foreground hover:shadow-[2px_2px_0px_hsl(var(--foreground))] hover:-translate-x-0.5 hover:-translate-y-0.5 transition\"\n        >\n          <X className=\"h-4 w-4\" />\n        </button>\n      ) : null}\n    </div>\n  )\n}\n\n// Helpers\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return '0 Bytes'\n  const k = 1024\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']\n  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]\n}\n\nfunction getFileIcon(mimeType: string) {\n  if (mimeType.startsWith('image/')) return Image\n  if (mimeType.startsWith('video/')) return FileVideo\n  if (mimeType.startsWith('audio/')) return FileAudio\n  if (mimeType.includes('pdf') || mimeType.includes('document')) return FileText\n  if (mimeType.includes('code') || mimeType.includes('javascript') || mimeType.includes('json'))\n    return FileCode\n  return File\n}\n\nexport { Dropzone, FileList, dropzoneVariants }\n",
      "type": "registry:ui",
      "target": "components/ui/dropzone.tsx"
    }
  ],
  "type": "registry:ui"
}