{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone",
  "type": "registry:component",
  "title": "Dropzone",
  "description": "Drag-and-drop (or click) upload area wired to files-sdk/react.",
  "dependencies": [
    "files-sdk",
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/files-sdk/dropzone/dropzone.tsx",
      "type": "registry:component",
      "target": "components/files-sdk/dropzone.tsx",
      "content": "\"use client\";\n\nimport type { UseFilesResult } from \"files-sdk/react\";\nimport {\n  CheckCircle2Icon,\n  Loader2Icon,\n  UploadIcon,\n  XCircleIcon,\n} from \"lucide-react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface UploadedEntry {\n  key: string;\n  /** Display name — the relative path for folder uploads, else the file name. */\n  name: string;\n}\n\ninterface PendingFile {\n  file: File;\n  /** Path relative to the picked/dropped root (e.g. `docs/guide.md`). Empty for plain files. */\n  path: string;\n}\n\ninterface DropzoneContextValue {\n  accept?: string;\n  directory: boolean;\n  maxFiles: number;\n  maxSize?: number;\n  isUploading: boolean;\n  uploaded: UploadedEntry[];\n  /** Failure summary for the most recent batch, if any. */\n  error?: string;\n  open: () => void;\n}\n\nconst formatBytes = (bytes: number): string => {\n  if (bytes === 0) {\n    return \"0 B\";\n  }\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const exponent = Math.min(\n    Math.floor(Math.log(bytes) / Math.log(1024)),\n    units.length - 1\n  );\n  return `${(bytes / 1024 ** exponent).toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;\n};\n\nconst DropzoneContext = createContext<DropzoneContextValue | null>(null);\n\nconst useDropzoneContext = (): DropzoneContextValue => {\n  const ctx = useContext(DropzoneContext);\n  if (!ctx) {\n    throw new Error(\"Dropzone components must be used inside <Dropzone>.\");\n  }\n  return ctx;\n};\n\n/** Drain a directory reader — `readEntries` returns results in batches of ≤100. */\nconst readAllEntries = (\n  reader: FileSystemDirectoryReader\n): Promise<FileSystemEntry[]> =>\n  // oxlint-disable-next-line promise/avoid-new -- readEntries is callback-only; there is no promise API to reuse\n  new Promise((resolve, reject) => {\n    const entries: FileSystemEntry[] = [];\n    const drain = (): void => {\n      reader.readEntries((batch) => {\n        if (batch.length === 0) {\n          resolve(entries);\n          return;\n        }\n        entries.push(...batch);\n        drain();\n      }, reject);\n    };\n    drain();\n  });\n\nconst entryFile = (entry: FileSystemFileEntry): Promise<File> =>\n  // oxlint-disable-next-line promise/avoid-new -- FileSystemFileEntry.file is callback-only; there is no promise API to reuse\n  new Promise((resolve, reject) => {\n    entry.file(resolve, reject);\n  });\n\nconst traverseEntry = async (\n  entry: FileSystemEntry\n): Promise<PendingFile[]> => {\n  if (entry.isFile) {\n    const file = await entryFile(entry as FileSystemFileEntry);\n    // fullPath is absolute (`/folder/sub/file.txt`) — strip the leading slash so\n    // keys mirror webkitRelativePath and include the dropped folder's name.\n    return [{ file, path: entry.fullPath.slice(1) }];\n  }\n  if (entry.isDirectory) {\n    const children = await readAllEntries(\n      (entry as FileSystemDirectoryEntry).createReader()\n    );\n    const nested = await Promise.all(children.map(traverseEntry));\n    return nested.flat();\n  }\n  return [];\n};\n\n/**\n * Flatten a drop into files. Entries must be grabbed synchronously — the\n * DataTransfer goes inert once the event handler yields — after which directory\n * traversal can run async. Plain files keep an empty path so the server can\n * still mint their keys.\n */\nconst collectDropped = async (\n  dataTransfer: DataTransfer\n): Promise<PendingFile[]> => {\n  const flat: PendingFile[] = [];\n  const directories: FileSystemEntry[] = [];\n  for (const item of dataTransfer.items) {\n    if (item.kind !== \"file\") {\n      continue;\n    }\n    const entry = item.webkitGetAsEntry?.();\n    if (entry?.isDirectory) {\n      directories.push(entry);\n      continue;\n    }\n    const file = item.getAsFile();\n    if (file) {\n      flat.push({ file, path: \"\" });\n    }\n  }\n  if (flat.length === 0 && directories.length === 0) {\n    return [...dataTransfer.files].map((file) => ({ file, path: \"\" }));\n  }\n  const nested = await Promise.all(directories.map(traverseEntry));\n  return [...flat, ...nested.flat()];\n};\n\nexport interface DropzoneProps {\n  /** A `useFiles()` instance — the dropzone uploads through it. */\n  files: UseFilesResult;\n  /** Key prefix (folder) for explicit keys, e.g. `\"docs/\"`. Empty = server mints the key. */\n  prefix?: string;\n  /** `accept` attribute for the file input, e.g. `\"image/*\"`. */\n  accept?: string;\n  /**\n   * Accept whole folders: the picker selects a directory and dropped folders\n   * are traversed recursively, with relative paths preserved in keys\n   * (`prefix + folder/sub/file.ext`).\n   */\n  directory?: boolean;\n  /** Max files per drop. Default 1, or unlimited when `directory` is set. */\n  maxFiles?: number;\n  /** Max bytes per file; larger files are reported as failed. */\n  maxSize?: number;\n  /** Called after each successful upload. */\n  onUploaded?: (entry: UploadedEntry) => void;\n  /** Called for each file that fails to upload or is rejected client-side. */\n  onError?: (error: Error, file: File) => void;\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Drag-and-drop (or click) upload area wired to `files-sdk/react`. Compose with\n * `<DropzoneContent />`, `<DropzoneEmptyState />` and `<DropzoneError />`, or\n * pass your own children. The prompt stays visible after uploads so users can\n * keep adding files.\n */\nexport const Dropzone = ({\n  files,\n  prefix = \"\",\n  accept,\n  directory = false,\n  maxFiles: maxFilesProp,\n  maxSize,\n  onUploaded,\n  onError,\n  className,\n  children,\n}: DropzoneProps) => {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [isDragActive, setIsDragActive] = useState(false);\n  const [uploaded, setUploaded] = useState<UploadedEntry[]>([]);\n  const [errorMessage, setErrorMessage] = useState<string | undefined>();\n\n  const maxFiles = maxFilesProp ?? (directory ? Number.POSITIVE_INFINITY : 1);\n\n  const upload = useCallback(\n    async (pending: PendingFile[]) => {\n      if (!pending.length) {\n        return;\n      }\n      setErrorMessage(undefined);\n      const failures: string[] = [];\n      const fail = (name: string, file: File, cause: Error): void => {\n        failures.push(`${name} (${cause.message})`);\n        onError?.(cause, file);\n      };\n      const batch = pending.slice(0, maxFiles);\n      for (const { file, path } of batch) {\n        const name = path || file.name;\n        if (maxSize && file.size > maxSize) {\n          fail(name, file, new Error(`larger than ${formatBytes(maxSize)}`));\n          continue;\n        }\n        let key: string | undefined;\n        if (path) {\n          key = `${prefix}${path}`;\n        } else if (prefix) {\n          key = `${prefix}${file.name}`;\n        }\n        try {\n          const result = key\n            ? // eslint-disable-next-line no-await-in-loop -- uploads run sequentially to avoid firing an unbounded burst of parallel requests at the server\n              await files.upload(key, file, { contentType: file.type })\n            : // eslint-disable-next-line no-await-in-loop -- uploads run sequentially to avoid firing an unbounded burst of parallel requests at the server\n              await files.upload(file);\n          const entry: UploadedEntry = { key: result.key, name };\n          setUploaded((prev) => [...prev, entry]);\n          onUploaded?.(entry);\n        } catch (error) {\n          fail(\n            name,\n            file,\n            error instanceof Error ? error : new Error(String(error))\n          );\n        }\n      }\n      if (pending.length > batch.length) {\n        const skipped = pending.length - batch.length;\n        failures.push(\n          `${skipped} file${skipped === 1 ? \"\" : \"s\"} over the ${maxFiles}-file limit`\n        );\n      }\n      if (failures.length) {\n        setErrorMessage(\n          failures.length === 1\n            ? `Upload failed: ${failures[0]}`\n            : `${failures.length} uploads failed: ${failures.join(\", \")}`\n        );\n      }\n    },\n    [files, maxFiles, maxSize, onError, onUploaded, prefix]\n  );\n\n  // Called synchronously from the drop event so collectDropped can grab the\n  // DataTransfer entries before the handler yields.\n  const handleDrop = useCallback(\n    async (dataTransfer: DataTransfer) => {\n      await upload(await collectDropped(dataTransfer));\n    },\n    [upload]\n  );\n\n  const open = useCallback(() => inputRef.current?.click(), []);\n\n  const contextValue = useMemo(\n    () => ({\n      accept,\n      directory,\n      error: errorMessage,\n      isUploading: files.isUploading,\n      maxFiles,\n      maxSize,\n      open,\n      uploaded,\n    }),\n    [\n      accept,\n      directory,\n      errorMessage,\n      files.isUploading,\n      maxFiles,\n      maxSize,\n      open,\n      uploaded,\n    ]\n  );\n\n  return (\n    <DropzoneContext.Provider value={contextValue}>\n      <Button\n        className={cn(\n          \"relative flex h-auto w-full flex-col items-center justify-center gap-2 overflow-hidden p-8\",\n          isDragActive && \"border-primary ring-1 ring-primary\",\n          className\n        )}\n        disabled={files.isUploading}\n        onClick={open}\n        onDragLeave={() => setIsDragActive(false)}\n        onDragOver={(event) => {\n          event.preventDefault();\n          setIsDragActive(true);\n        }}\n        onDrop={(event) => {\n          event.preventDefault();\n          setIsDragActive(false);\n          void handleDrop(event.dataTransfer);\n        }}\n        type=\"button\"\n        variant=\"outline\"\n      >\n        <input\n          accept={accept}\n          aria-label=\"Upload files\"\n          className=\"hidden\"\n          multiple={maxFiles > 1}\n          onChange={(event) => {\n            const picked = [...(event.currentTarget.files ?? [])].map(\n              (file) => ({ file, path: file.webkitRelativePath || \"\" })\n            );\n            void upload(picked);\n            event.currentTarget.value = \"\";\n          }}\n          ref={(node) => {\n            inputRef.current = node;\n            // React's types don't know the non-standard directory-picker\n            // attribute, so set it imperatively.\n            node?.toggleAttribute(\"webkitdirectory\", directory);\n          }}\n          type=\"file\"\n        />\n        {children}\n      </Button>\n    </DropzoneContext.Provider>\n  );\n};\n\nexport interface DropzoneEmptyStateProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/** Default prompt — stays visible after uploads so more files can be added. */\nexport const DropzoneEmptyState = ({\n  className,\n  children,\n}: DropzoneEmptyStateProps) => {\n  const { accept, directory, isUploading, maxFiles, maxSize } =\n    useDropzoneContext();\n\n  if (children) {\n    return <div className={className}>{children}</div>;\n  }\n\n  let countLabel = \"1 file\";\n  if (directory) {\n    countLabel = Number.isFinite(maxFiles)\n      ? `folder · up to ${maxFiles} files`\n      : \"folder upload\";\n  } else if (maxFiles > 1) {\n    countLabel = `up to ${maxFiles} files`;\n  }\n\n  const prompt = directory\n    ? \"Drag & drop a folder or click to upload\"\n    : \"Drag & drop or click to upload\";\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-center justify-center gap-1 text-center\",\n        className\n      )}\n    >\n      {isUploading ? (\n        <Loader2Icon className=\"size-6 animate-spin text-muted-foreground\" />\n      ) : (\n        <UploadIcon className=\"size-6 text-muted-foreground\" />\n      )}\n      <p className=\"font-medium text-sm\">\n        {isUploading ? \"Uploading…\" : prompt}\n      </p>\n      <p className=\"text-muted-foreground text-xs\">\n        {accept ? `${accept} · ` : \"\"}\n        {countLabel}\n        {maxSize ? ` · max ${formatBytes(maxSize)}` : \"\"}\n      </p>\n    </div>\n  );\n};\n\nexport interface DropzoneContentProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/** Success summary shown once one or more uploads have completed. */\nexport const DropzoneContent = ({\n  className,\n  children,\n}: DropzoneContentProps) => {\n  const { uploaded } = useDropzoneContext();\n\n  if (!uploaded.length) {\n    return null;\n  }\n\n  if (children) {\n    return <div className={className}>{children}</div>;\n  }\n\n  return (\n    <p\n      className={cn(\"flex items-center gap-1.5 font-medium text-sm\", className)}\n    >\n      <CheckCircle2Icon className=\"size-4 text-primary\" />\n      {uploaded.length === 1\n        ? `Uploaded ${uploaded[0].name}`\n        : `${uploaded.length} files uploaded`}\n    </p>\n  );\n};\n\nexport interface DropzoneErrorProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/** Failure summary — renders only when the most recent batch had errors. */\nexport const DropzoneError = ({ className, children }: DropzoneErrorProps) => {\n  const { error } = useDropzoneContext();\n\n  if (!error) {\n    return null;\n  }\n\n  if (children) {\n    return <div className={className}>{children}</div>;\n  }\n\n  return (\n    <p\n      className={cn(\n        \"flex items-center gap-1.5 text-destructive text-sm\",\n        className\n      )}\n    >\n      <XCircleIcon className=\"size-4\" />\n      {error}\n    </p>\n  );\n};\n"
    }
  ]
}
