{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "type": "registry:ui",
  "title": "File Upload",
  "description": "Compact file selection with restrictions and recoverable per-file feedback.",
  "dependencies": [
    "react-aria-components@^1.21.1",
    "lucide-react@^1.47.0"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/button.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/file-upload/use-file-upload.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/file-upload/use-file-upload.ts",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  validateUploadFiles,\n  type FileUploadRules,\n  type FileUploadRejection,\n} from \"./file-upload-validation\";\n\nexport type FileUploadStatus =\n  \"selected\" | \"queued\" | \"uploading\" | \"uploaded\" | \"error\" | \"cancelled\";\nexport interface FileUploadEntry {\n  id: string;\n  file: File;\n  status: FileUploadStatus;\n  progress?: number;\n  error?: string;\n}\nexport interface FileUploadContext {\n  id: string;\n  signal: AbortSignal;\n  /** Transfer percent. Omit calls if progress is unknown. */\n  onProgress: (percent: number) => void;\n}\nexport type FileUploadHandler = (\n  file: File,\n  context: FileUploadContext,\n) => Promise<void>;\nexport interface FileUploadOptions extends FileUploadRules {\n  disabled?: boolean;\n  autoUpload?: boolean;\n  onUpload?: FileUploadHandler;\n  onFilesChange?: (files: readonly FileUploadEntry[]) => void;\n}\n\nexport function useFileUpload(options: FileUploadOptions, prefix: string) {\n  const [entries, setEntries] = useState<FileUploadEntry[]>([]);\n  const [errors, setErrors] = useState<\n    (FileUploadRejection & { id: string })[]\n  >([]);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const current = useRef<FileUploadEntry[]>([]);\n  const config = useRef(options);\n  const serial = useRef(0);\n  const mounted = useRef(true);\n  const attempts = useRef(new Map<string, AbortController>());\n  useEffect(() => {\n    config.current = options;\n    // Resume pending transfers when the host re-enables the field or restores its transport.\n    pump();\n  });\n  useEffect(() => {\n    mounted.current = true;\n    const active = attempts.current;\n    return () => {\n      mounted.current = false;\n      for (const controller of active.values()) controller.abort();\n      active.clear();\n    };\n  }, []);\n\n  function publish(next: FileUploadEntry[]) {\n    if (!mounted.current) return;\n    current.current = next;\n    setEntries(next);\n    config.current.onFilesChange?.(next);\n  }\n  function update(id: string, patch: Partial<FileUploadEntry>) {\n    publish(\n      current.current.map((entry) =>\n        entry.id === id ? { ...entry, ...patch } : entry,\n      ),\n    );\n  }\n  function cancelAttempt(id: string) {\n    const controller = attempts.current.get(id);\n    attempts.current.delete(id);\n    controller?.abort();\n  }\n  function pump() {\n    const upload = config.current.onUpload;\n    if (!mounted.current || !upload || config.current.disabled) return;\n    while (attempts.current.size < 3) {\n      const entry = current.current.find((item) => item.status === \"queued\");\n      if (!entry) break;\n      const controller = new AbortController();\n      attempts.current.set(entry.id, controller);\n      update(entry.id, {\n        status: \"uploading\",\n        progress: undefined,\n        error: undefined,\n      });\n      const isCurrent = () =>\n        mounted.current &&\n        attempts.current.get(entry.id) === controller &&\n        !controller.signal.aborted;\n      // The async boundary handles callbacks that throw synchronously as well as rejected promises.\n      void (async () => {\n        try {\n          await upload(entry.file, {\n            id: entry.id,\n            signal: controller.signal,\n            onProgress: (percent) => {\n              if (isCurrent() && Number.isFinite(percent))\n                update(entry.id, {\n                  progress: Math.min(100, Math.max(0, percent)),\n                });\n            },\n          });\n          if (isCurrent()) {\n            update(entry.id, { status: \"uploaded\", progress: 100 });\n            setAnnouncement(`${entry.file.name} uploaded.`);\n          }\n        } catch (error) {\n          if (isCurrent()) {\n            const message =\n              error instanceof Error && error.message.trim()\n                ? error.message\n                : \"Upload failed. Try again.\";\n            update(entry.id, {\n              status: \"error\",\n              error: message,\n              progress: undefined,\n            });\n            setAnnouncement(`${entry.file.name}: ${message}`);\n          }\n        } finally {\n          if (attempts.current.get(entry.id) === controller) {\n            attempts.current.delete(entry.id);\n            pump();\n          }\n        }\n      })();\n    }\n  }\n  function reject(rejected: FileUploadRejection[]) {\n    if (!mounted.current) return;\n    setErrors(\n      rejected.map((error) => ({\n        ...error,\n        id: `${prefix}-error-${++serial.current}`,\n      })),\n    );\n  }\n  function add(\n    files: readonly File[],\n    replace = false,\n    extraErrors: FileUploadRejection[] = [],\n  ) {\n    const rules = config.current;\n    if (\n      !mounted.current ||\n      rules.disabled ||\n      (!files.length && !extraErrors.length)\n    )\n      return;\n    const existing =\n      replace && !rules.multiple\n        ? []\n        : current.current.map((entry) => entry.file);\n    const result = validateUploadFiles(files, existing, rules);\n    const rejected = [...result.rejected, ...extraErrors];\n    reject(rejected);\n    if (result.accepted.length) {\n      if (replace && !rules.multiple)\n        for (const entry of current.current) cancelAttempt(entry.id);\n      const added = result.accepted.map((file): FileUploadEntry => ({\n        id: `${prefix}-file-${++serial.current}`,\n        file,\n        status: rules.autoUpload && rules.onUpload ? \"queued\" : \"selected\",\n      }));\n      publish([\n        ...(replace && !rules.multiple ? [] : current.current),\n        ...added,\n      ]);\n    }\n    setAnnouncement(\n      `${result.accepted.length} added. ${rejected.length} not added.${rejected.length ? \" Review errors below the file list.\" : \"\"}`,\n    );\n    pump();\n  }\n  function remove(id: string) {\n    if (config.current.disabled) return;\n    const entry = current.current.find((item) => item.id === id);\n    cancelAttempt(id);\n    publish(current.current.filter((item) => item.id !== id));\n    setAnnouncement(`${entry?.file.name ?? \"File\"} removed.`);\n    pump();\n  }\n  function cancel(id: string) {\n    cancelAttempt(id);\n    update(id, { status: \"cancelled\", progress: undefined });\n    setAnnouncement(\"Upload cancelled. You can retry when ready.\");\n    pump();\n  }\n  function start(id?: string) {\n    if (config.current.disabled || !config.current.onUpload) return;\n    publish(\n      current.current.map((entry) =>\n        (\n          id\n            ? entry.id === id &&\n              [\"error\", \"cancelled\", \"selected\"].includes(entry.status)\n            : entry.status === \"selected\"\n        )\n          ? {\n              ...entry,\n              status: \"queued\",\n              error: undefined,\n              progress: undefined,\n            }\n          : entry,\n      ),\n    );\n    pump();\n  }\n  return {\n    entries,\n    errors,\n    announcement,\n    add,\n    remove,\n    cancel,\n    start,\n    dismiss: (id: string) =>\n      setErrors((list) => list.filter((error) => error.id !== id)),\n  };\n}\n"
    },
    {
      "path": "packages/components/src/components/file-upload/file-upload.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/file-upload/file-upload.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  Button as AriaButton,\n  DropZone,\n  FileTrigger,\n  type DropZoneProps,\n} from \"react-aria-components\";\nimport { FileText, Trash2, Upload, X } from \"lucide-react\";\nimport { Button, buttonClassNames } from \"../button\";\nimport { cn } from \"../../utils/cn\";\nimport {\n  formatFileSize,\n  type FileUploadRejection,\n} from \"./file-upload-validation\";\nimport { useFileUpload, type FileUploadOptions } from \"./use-file-upload\";\n\nexport interface FileUploadProps extends FileUploadOptions {\n  label?: string;\n  description?: string;\n  className?: string;\n}\n\nfunction FilePreview({ file }: { file: File }) {\n  const [preview, setPreview] = useState<{ file: File; url: string }>();\n  const [failed, setFailed] = useState(false);\n  useEffect(() => {\n    if (\n      !/^image\\/(png|jpeg|webp|gif|avif)$/.test(file.type) ||\n      typeof URL.createObjectURL !== \"function\"\n    )\n      return;\n    const url = URL.createObjectURL(file);\n    // Object URLs are browser resources and must be created after hydration.\n    // eslint-disable-next-line react-hooks/set-state-in-effect\n    setPreview({ file, url });\n    return () => URL.revokeObjectURL(url);\n  }, [file]);\n  return preview?.file === file && !failed ? (\n    <img\n      src={preview.url}\n      alt=\"\"\n      width={24}\n      height={24}\n      onError={() => setFailed(true)}\n      className=\"size-6 shrink-0 rounded object-cover\"\n    />\n  ) : (\n    <FileText\n      aria-hidden=\"true\"\n      className=\"text-muted-foreground size-6 shrink-0\"\n    />\n  );\n}\n\nfunction FileName({ name }: { name: string }) {\n  const dot = name.lastIndexOf(\".\");\n  const split = dot > 0 && name.length - dot < 16 ? dot : name.length;\n  return (\n    <p title={name} className=\"flex min-w-0\">\n      <span className=\"truncate\">{name.slice(0, split)}</span>\n      <span className=\"shrink-0\">{name.slice(split)}</span>\n    </p>\n  );\n}\n\nexport function FileUpload({\n  label = \"Attachments\",\n  description,\n  className,\n  ...options\n}: FileUploadProps) {\n  const id = useId();\n  const trigger = useRef<HTMLButtonElement>(null);\n  const fileDrag = useRef(false);\n  const dropSequence = useRef(Promise.resolve());\n  const queue = useFileUpload(options, id);\n  const {\n    multiple = false,\n    accept,\n    maxFiles,\n    maxFileSize,\n    disabled = false,\n    onUpload,\n  } = options;\n  const restrictions = [\n    accept\n      ? accept\n          .split(\",\")\n          .map((type) =>\n            type.trim().startsWith(\".\")\n              ? type.trim().slice(1).toUpperCase()\n              : type.trim(),\n          )\n          .filter(Boolean)\n          .join(\", \")\n      : \"All file types\",\n    maxFileSize === undefined ? \"\" : `Max ${formatFileSize(maxFileSize)} each`,\n    multiple\n      ? maxFiles === undefined\n        ? \"Multiple files\"\n        : `Up to ${maxFiles} files`\n      : \"One file\",\n  ]\n    .filter(Boolean)\n    .join(\" · \");\n  const selected = queue.entries.filter(\n    (entry) => entry.status === \"selected\",\n  ).length;\n  const uploading = queue.entries.some(\n    (entry) => entry.status === \"uploading\" || entry.status === \"queued\",\n  );\n  const failures =\n    queue.entries.filter((entry) => entry.status === \"error\").length +\n    queue.errors.length;\n  const handleDrop: NonNullable<DropZoneProps[\"onDrop\"]> = (event) => {\n    // Begin reading while event data is accessible; apply separate drops in order.\n    const read = Promise.all(\n      event.items.map(\n        async (item): Promise<File | FileUploadRejection | null> => {\n          if (item.kind === \"directory\")\n            return {\n              name: item.name,\n              code: \"directory\",\n              message: \"Folders are not supported. Choose individual files.\",\n            };\n          if (item.kind !== \"file\") return null;\n          try {\n            return await item.getFile();\n          } catch {\n            return {\n              name: item.name,\n              code: \"unreadable\",\n              message: \"This file could not be read. Choose it again.\",\n            };\n          }\n        },\n      ),\n    );\n    dropSequence.current = dropSequence.current\n      .then(async () => {\n        const items = await read;\n        const files: File[] = [];\n        const rejected: FileUploadRejection[] = [];\n        for (const item of items) {\n          if (!item) continue;\n          if (\"code\" in item) rejected.push(item);\n          else files.push(item);\n        }\n        queue.add(files, false, rejected);\n      })\n      .catch(() =>\n        queue.add([], false, [\n          {\n            name: \"Dropped files\",\n            code: \"unreadable\",\n            message: \"Files could not be read. Please choose them again.\",\n          },\n        ]),\n      );\n  };\n  return (\n    <section\n      onDragEnterCapture={(event) => {\n        fileDrag.current = event.dataTransfer.types.includes(\"Files\");\n      }}\n      aria-labelledby={`${id}-label`}\n      data-slot=\"file-upload\"\n      className={cn(\"text-foreground min-w-0 space-y-3 text-sm\", className)}\n    >\n      <div>\n        <h3 id={`${id}-label`} className=\"font-medium\">\n          {label}\n        </h3>\n        {description && (\n          <p className=\"text-muted-foreground mt-1 text-sm\">{description}</p>\n        )}\n      </div>\n      <DropZone\n        aria-label={`Add files to ${label}`}\n        aria-describedby={`${id}-rules`}\n        isDisabled={disabled}\n        getDropOperation={() => (fileDrag.current ? \"copy\" : \"cancel\")}\n        onDrop={handleDrop}\n        data-slot=\"file-upload-drop-zone\"\n        className=\"border-border bg-muted/20 data-[drop-target]:border-primary data-[drop-target]:bg-primary/5 data-[focus-visible]:ring-ring grid grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2 rounded-lg border px-4 py-3 data-[disabled]:opacity-50 data-[focus-visible]:ring-2 sm:grid-cols-[1.25rem_minmax(0,1fr)_auto]\"\n      >\n        {({ isDropTarget }) => (\n          <>\n            <Upload\n              aria-hidden=\"true\"\n              className=\"text-primary size-5 shrink-0\"\n            />\n            <div className=\"min-w-0 flex-1\">\n              <p>\n                {isDropTarget\n                  ? \"Drop to add files\"\n                  : !multiple && queue.entries.length\n                    ? \"Remove or replace your file\"\n                    : `Drop ${multiple ? \"files\" : \"a file\"} here`}\n              </p>\n              <p\n                id={`${id}-rules`}\n                className=\"text-muted-foreground mt-1 text-xs [overflow-wrap:anywhere]\"\n              >\n                {restrictions}\n              </p>\n            </div>\n            <FileTrigger\n              allowsMultiple={multiple}\n              acceptedFileTypes={accept\n                ?.split(\",\")\n                .map((p) => p.trim())\n                .filter(Boolean)}\n              onSelect={(files) => {\n                if (files?.length) queue.add(Array.from(files), !multiple);\n              }}\n            >\n              <AriaButton\n                ref={trigger}\n                isDisabled={disabled}\n                aria-describedby={`${id}-rules`}\n                className={buttonClassNames({\n                  variant: \"outline\",\n                  size: \"sm\",\n                  className:\n                    \"col-start-2 justify-self-start sm:col-start-auto pointer-coarse:min-h-11\",\n                })}\n              >\n                {!multiple && queue.entries.length\n                  ? \"Replace file\"\n                  : multiple\n                    ? \"Choose files\"\n                    : \"Choose file\"}\n              </AriaButton>\n            </FileTrigger>\n          </>\n        )}\n      </DropZone>\n      {queue.entries.length > 0 && (\n        // Safari needs an explicit list role when Tailwind removes list markers.\n        // eslint-disable-next-line jsx-a11y/no-redundant-roles\n        <ul\n          aria-label=\"Selected files\"\n          role=\"list\"\n          className=\"border-border divide-border divide-y rounded-lg border\"\n        >\n          {queue.entries.map((entry) => {\n            const active =\n              entry.status === \"uploading\" || entry.status === \"queued\";\n            const retryable =\n              entry.status === \"error\" || entry.status === \"cancelled\";\n            const status =\n              entry.status === \"selected\"\n                ? \"\"\n                : entry.status === \"uploading\"\n                  ? entry.progress === undefined\n                    ? \"Uploading…\"\n                    : entry.progress === 100\n                      ? \"Finishing…\"\n                      : `${Math.round(entry.progress)}%`\n                  : entry.status === \"uploaded\"\n                    ? \"Uploaded\"\n                    : entry.status === \"queued\"\n                      ? \"Queued\"\n                      : entry.status === \"cancelled\"\n                        ? \"Cancelled\"\n                        : \"Upload failed\";\n            return (\n              <li\n                key={entry.id}\n                data-state={entry.status}\n                data-slot=\"file-upload-item\"\n                className=\"flex min-w-0 items-center gap-3 px-3 py-[max(0.625rem,var(--dt-density-gap))]\"\n              >\n                <FilePreview file={entry.file} />\n                <div className=\"min-w-0 flex-1\">\n                  <FileName name={entry.file.name} />\n                  {entry.status === \"uploading\" && (\n                    <div\n                      role=\"progressbar\"\n                      aria-label={`Uploading ${entry.file.name}`}\n                      aria-valuemin={0}\n                      aria-valuemax={100}\n                      aria-valuenow={entry.progress}\n                      aria-valuetext={\n                        entry.progress === undefined\n                          ? \"Uploading\"\n                          : entry.progress === 100\n                            ? \"Finishing upload\"\n                            : `${Math.round(entry.progress)} percent`\n                      }\n                      data-slot=\"file-upload-progress\"\n                      className=\"bg-muted mt-1.5 h-[3px] w-full max-w-48 overflow-hidden rounded-full\"\n                    >\n                      <div\n                        className={cn(\n                          \"bg-primary h-full rounded-full\",\n                          entry.progress === undefined && \"opacity-60\",\n                        )}\n                        style={{ width: `${entry.progress ?? 100}%` }}\n                      />\n                    </div>\n                  )}\n                  <p\n                    className={cn(\n                      \"mt-1 text-xs\",\n                      entry.status === \"error\"\n                        ? \"text-destructive\"\n                        : \"text-muted-foreground\",\n                    )}\n                  >\n                    {formatFileSize(entry.file.size)}\n                    {status && ` · ${status}`}\n                  </p>\n                  {entry.error && (\n                    <p className=\"text-destructive mt-1 text-xs [overflow-wrap:anywhere]\">\n                      {entry.error}\n                    </p>\n                  )}\n                </div>\n                <div className=\"flex shrink-0 items-center gap-0.5\">\n                  {/* Keep this control mounted as its action changes so async completion preserves focus. */}\n                  <Button\n                    variant={\n                      active || (retryable && onUpload) ? \"link\" : \"ghost\"\n                    }\n                    size={active || (retryable && onUpload) ? \"xs\" : \"sm\"}\n                    disabled={!active && disabled}\n                    aria-label={`${active ? \"Cancel\" : retryable && onUpload ? \"Retry\" : \"Remove\"} ${entry.file.name}`}\n                    className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\"\n                    onClick={() => {\n                      if (active) queue.cancel(entry.id);\n                      else if (retryable && onUpload) queue.start(entry.id);\n                      else queue.remove(entry.id);\n                      trigger.current?.focus();\n                    }}\n                  >\n                    {active ? (\n                      \"Cancel\"\n                    ) : retryable && onUpload ? (\n                      \"Retry\"\n                    ) : (\n                      <Trash2 aria-hidden=\"true\" className=\"size-3.5\" />\n                    )}\n                  </Button>\n                  {!active && retryable && onUpload && (\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      disabled={disabled}\n                      aria-label={`Remove ${entry.file.name}`}\n                      className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\"\n                      onClick={() => {\n                        queue.remove(entry.id);\n                        trigger.current?.focus();\n                      }}\n                    >\n                      <Trash2 aria-hidden=\"true\" className=\"size-3.5\" />\n                    </Button>\n                  )}\n                </div>\n              </li>\n            );\n          })}\n        </ul>\n      )}\n      {queue.errors.length > 0 && (\n        // Safari needs an explicit list role when Tailwind removes list markers.\n        // eslint-disable-next-line jsx-a11y/no-redundant-roles\n        <ul aria-label=\"Files not added\" role=\"list\" className=\"space-y-2\">\n          {queue.errors.map((error) => (\n            <li key={error.id} className=\"flex items-start gap-2 text-xs\">\n              <div className=\"min-w-0 flex-1 [overflow-wrap:anywhere]\">\n                <p className=\"font-medium\">{error.name}</p>\n                <p className=\"text-destructive mt-0.5\">{error.message}</p>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                disabled={disabled}\n                className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\"\n                aria-label={`Dismiss error for ${error.name}`}\n                onClick={() => {\n                  queue.dismiss(error.id);\n                  trigger.current?.focus();\n                }}\n              >\n                <X aria-hidden=\"true\" className=\"size-3.5\" />\n              </Button>\n            </li>\n          ))}\n        </ul>\n      )}\n      {queue.entries.length > 0 && (\n        <div className=\"flex flex-wrap items-center justify-between gap-3\">\n          <p className=\"text-muted-foreground text-xs\">\n            {queue.entries.length}{\" \"}\n            {queue.entries.length === 1 ? \"file\" : \"files\"} ·{\" \"}\n            {formatFileSize(\n              queue.entries.reduce(\n                (total, entry) => total + entry.file.size,\n                0,\n              ),\n            )}{\" \"}\n            total\n            {failures > 0 && (\n              <span className=\"text-destructive\">\n                {\" \"}\n                · {failures} need attention\n              </span>\n            )}\n          </p>\n          {onUpload && !options.autoUpload && (\n            <Button\n              size=\"sm\"\n              disabled={disabled || !selected}\n              onClick={() => queue.start()}\n            >\n              {selected\n                ? `Upload ${selected === 1 ? \"file\" : `${selected} files`}`\n                : uploading\n                  ? \"Uploading…\"\n                  : \"No files to upload\"}\n            </Button>\n          )}\n        </div>\n      )}\n      <p\n        role=\"status\"\n        aria-live=\"polite\"\n        aria-atomic=\"true\"\n        className=\"sr-only\"\n      >\n        {queue.announcement}\n      </p>\n    </section>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/components/file-upload/file-upload-validation.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/file-upload/file-upload-validation.ts",
      "content": "export interface FileUploadRules {\n  /** Comma-separated extensions, MIME types, or MIME wildcards. */\n  accept?: string;\n  multiple?: boolean;\n  maxFiles?: number;\n  /** Inclusive per-file limit in bytes. */\n  maxFileSize?: number;\n}\n\nexport type FileUploadRejectionCode =\n  \"type\" | \"size\" | \"count\" | \"duplicate\" | \"directory\" | \"unreadable\";\n\nexport interface FileUploadRejection {\n  name: string;\n  code: FileUploadRejectionCode;\n  message: string;\n}\n\nexport function formatFileSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${Number((bytes / 1024).toFixed(1))} KiB`;\n  return `${Number((bytes / (1024 * 1024)).toFixed(1))} MiB`;\n}\n\nfunction identity(file: File) {\n  return JSON.stringify([file.name, file.size, file.lastModified, file.type]);\n}\n\n/** Metadata validation for UX only. The server must validate file contents. */\nexport function validateUploadFiles(\n  files: readonly File[],\n  existing: readonly File[] = [],\n  {\n    accept = \"\",\n    multiple = false,\n    maxFiles,\n    maxFileSize,\n  }: FileUploadRules = {},\n): { accepted: File[]; rejected: FileUploadRejection[] } {\n  const accepted: File[] = [];\n  const rejected: FileUploadRejection[] = [];\n  const patterns = accept\n    .toLowerCase()\n    .split(\",\")\n    .map((p) => p.trim())\n    .filter(Boolean);\n  const capacity = multiple ? Math.max(0, Math.floor(maxFiles ?? Infinity)) : 1;\n  const seen = new Set(existing.map(identity));\n  for (const file of files) {\n    let error: Omit<FileUploadRejection, \"name\"> | undefined;\n    if (!multiple && files.length > 1) {\n      error = { code: \"count\", message: \"Choose one file at a time.\" };\n    } else if (\n      patterns.length &&\n      !patterns.some((pattern) =>\n        pattern.startsWith(\".\")\n          ? file.name.toLowerCase().endsWith(pattern)\n          : pattern.endsWith(\"/*\")\n            ? file.type.toLowerCase().startsWith(pattern.slice(0, -1))\n            : file.type.toLowerCase() === pattern,\n      )\n    ) {\n      error = {\n        code: \"type\",\n        message: `File type not supported. Choose ${accept}.`,\n      };\n    } else if (maxFileSize !== undefined && file.size > maxFileSize) {\n      error = {\n        code: \"size\",\n        message: `Too large — maximum ${formatFileSize(maxFileSize)} per file.`,\n      };\n    } else if (seen.has(identity(file))) {\n      error = {\n        code: \"duplicate\",\n        message: \"This file is already in the list.\",\n      };\n    } else if (existing.length + accepted.length >= capacity) {\n      error = {\n        code: \"count\",\n        message: `Maximum ${capacity} ${capacity === 1 ? \"file\" : \"files\"}. Remove a file first.`,\n      };\n    }\n    if (error) rejected.push({ name: file.name, ...error });\n    else {\n      accepted.push(file);\n      seen.add(identity(file));\n    }\n  }\n  return { accepted, rejected };\n}\n"
    },
    {
      "path": "packages/components/src/components/file-upload/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/file-upload/index.ts",
      "content": "export { FileUpload, type FileUploadProps } from \"./file-upload\";\nexport type {\n  FileUploadEntry,\n  FileUploadStatus,\n  FileUploadHandler,\n  FileUploadContext,\n} from \"./use-file-upload\";\nexport {\n  validateUploadFiles,\n  formatFileSize,\n  type FileUploadRules,\n  type FileUploadRejection,\n  type FileUploadRejectionCode,\n} from \"./file-upload-validation\";\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
