{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "title": "File upload",
  "description": "Dropzone and file list. Local files only — no upload.",
  "dependencies": [
    "lucide-react@^1.33.0"
  ],
  "registryDependencies": [
    "https://components.exe.xyz/r/utils.json"
  ],
  "files": [
    {
      "path": "src/components/ui/file-upload.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { FileIcon, UploadIcon, XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype FileUploadItem = {\n  id: string\n  file: File\n  error?: string\n}\n\nfunction formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n}\n\nfunction matchesAccept(file: File, accept?: string) {\n  if (!accept) return true\n  const tokens = accept.split(\",\").map((token) => token.trim().toLowerCase())\n  const name = file.name.toLowerCase()\n  const type = file.type.toLowerCase()\n  return tokens.some((token) => {\n    if (!token) return false\n    if (token.startsWith(\".\")) return name.endsWith(token)\n    if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1))\n    return type === token\n  })\n}\n\nfunction hintText(accept?: string, maxBytes?: number) {\n  const types = accept\n    ? accept\n        .split(\",\")\n        .map((token) => token.trim().replace(/^\\./, \"\").toUpperCase())\n        .filter(Boolean)\n        .join(\" · \")\n    : \"Any file\"\n  return maxBytes ? `${types} · max ${formatBytes(maxBytes)}` : types\n}\n\nfunction FileUpload({\n  className,\n  accept,\n  multiple = true,\n  maxBytes = 8 * 1024 * 1024,\n  disabled = false,\n  onFilesChange,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  accept?: string\n  multiple?: boolean\n  maxBytes?: number\n  disabled?: boolean\n  onFilesChange?: (files: File[]) => void\n}) {\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const dragCount = React.useRef(0)\n  const [items, setItems] = React.useState<FileUploadItem[]>([])\n  const [dragging, setDragging] = React.useState(false)\n  const [live, setLive] = React.useState(\"\")\n\n  const emit = React.useCallback(\n    (next: FileUploadItem[]) => {\n      setItems(next)\n      onFilesChange?.(\n        next.filter((item) => !item.error).map((item) => item.file)\n      )\n    },\n    [onFilesChange]\n  )\n\n  const addFiles = React.useCallback(\n    (list: FileList | File[]) => {\n      const incoming = Array.from(list)\n      if (incoming.length === 0) return\n\n      const mapped: FileUploadItem[] = incoming.map((file) => {\n        let error: string | undefined\n        if (!matchesAccept(file, accept)) {\n          error = \"Type not allowed\"\n        } else if (maxBytes && file.size > maxBytes) {\n          error = `Larger than ${formatBytes(maxBytes)}`\n        }\n        return {\n          id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,\n          file,\n          error,\n        }\n      })\n\n      const next = multiple ? [...items, ...mapped] : mapped.slice(0, 1)\n      emit(next)\n\n      const rejected = mapped.filter((item) => item.error).length\n      const added = mapped.length - rejected\n      if (rejected && added) {\n        setLive(`${added} added, ${rejected} rejected`)\n      } else if (rejected) {\n        setLive(mapped[0]?.error ?? \"File rejected\")\n      } else {\n        setLive(\n          added === 1 ? `${mapped[0].file.name} added` : `${added} files added`\n        )\n      }\n    },\n    [accept, emit, items, maxBytes, multiple]\n  )\n\n  const removeItem = (id: string) => {\n    const removed = items.find((item) => item.id === id)\n    emit(items.filter((item) => item.id !== id))\n    if (removed) setLive(`${removed.file.name} removed`)\n    if (inputRef.current) inputRef.current.value = \"\"\n  }\n\n  return (\n    <div\n      data-slot=\"file-upload\"\n      className={cn(\"flex w-full flex-col gap-2\", className)}\n      {...props}\n    >\n      <input\n        ref={inputRef}\n        type=\"file\"\n        className=\"sr-only\"\n        tabIndex={-1}\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(event) => {\n          if (event.target.files) addFiles(event.target.files)\n        }}\n      />\n      <button\n        type=\"button\"\n        data-slot=\"file-upload-dropzone\"\n        data-dragging={dragging || undefined}\n        aria-label=\"Upload file\"\n        disabled={disabled}\n        onClick={() => inputRef.current?.click()}\n        onDragEnter={(event) => {\n          event.preventDefault()\n          if (disabled) return\n          dragCount.current += 1\n          setDragging(true)\n        }}\n        onDragOver={(event) => {\n          event.preventDefault()\n          event.dataTransfer.dropEffect = disabled ? \"none\" : \"copy\"\n        }}\n        onDragLeave={(event) => {\n          event.preventDefault()\n          dragCount.current = Math.max(0, dragCount.current - 1)\n          if (dragCount.current === 0) setDragging(false)\n        }}\n        onDrop={(event) => {\n          event.preventDefault()\n          dragCount.current = 0\n          setDragging(false)\n          if (disabled) return\n          addFiles(event.dataTransfer.files)\n        }}\n        className={cn(\n          \"flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-[2px] border border-dashed border-border bg-muted/20 px-4 py-6 text-center transition-colors outline-none\",\n          \"hover:border-foreground/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50\",\n          \"data-[dragging=true]:border-primary data-[dragging=true]:bg-primary/5\",\n          \"disabled:pointer-events-none disabled:opacity-50\"\n        )}\n      >\n        <UploadIcon className=\"size-5 text-muted-foreground\" />\n        <span className=\"text-sm\">\n          {dragging ? \"Drop to add\" : \"Drop files here or browse\"}\n        </span>\n        <span className=\"font-mono text-[11px] text-muted-foreground\">\n          {hintText(accept, maxBytes)}\n        </span>\n      </button>\n      {items.length > 0 ? (\n        <ul data-slot=\"file-upload-list\" className=\"flex flex-col gap-1\">\n          {items.map((item) => (\n            <li\n              key={item.id}\n              data-slot=\"file-upload-item\"\n              data-invalid={item.error ? true : undefined}\n              className={cn(\n                \"flex items-center gap-2 rounded-[2px] border border-border bg-card px-2 py-1.5 text-sm\",\n                item.error && \"border-destructive text-destructive\"\n              )}\n            >\n              <FileIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n              <span className=\"min-w-0 flex-1 truncate font-mono text-[12px]\">\n                {item.file.name}\n              </span>\n              <span\n                className={cn(\n                  \"shrink-0 font-mono text-[11px]\",\n                  item.error ? \"text-destructive\" : \"text-muted-foreground\"\n                )}\n              >\n                {item.error ?? formatBytes(item.file.size)}\n              </span>\n              <button\n                type=\"button\"\n                aria-label={`Remove ${item.file.name}`}\n                onClick={() => removeItem(item.id)}\n                className=\"inline-flex size-6 shrink-0 items-center justify-center rounded-[2px] text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50\"\n              >\n                <XIcon className=\"size-3.5\" />\n              </button>\n            </li>\n          ))}\n        </ul>\n      ) : null}\n      <div className=\"sr-only\" aria-live=\"polite\">\n        {live}\n      </div>\n    </div>\n  )\n}\n\nexport { FileUpload, formatBytes }\nexport type { FileUploadItem }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}