{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upload",
  "title": "UploadField",
  "description": "File upload field for BuzzForm v2 with dropzone, avatar, gallery, and inline variants.",
  "registryDependencies": [
    "@buzzform/init",
    "button",
    "field"
  ],
  "files": [
    {
      "path": "registry/shadcn/fields/upload.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { UploadField as UploadFieldDef } from \"@buildnbuzz/form-react\";\nimport { useDataField } from \"@buildnbuzz/form-react\";\nimport { IconPlaceholder } from \"@/components/icon-placeholder\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Field,\n  FieldContent,\n  FieldDescription,\n  FieldError,\n  FieldGroup,\n  FieldLabel,\n} from \"@/components/ui/field\";\n\n// ---------------------------------------------------------------------------\n// UI Options defined inside the registry component\n// ---------------------------------------------------------------------------\n\nexport interface UploadUi {\n  /** Visual variant. Defaults to `\"dropzone\"`. */\n  variant?: \"dropzone\" | \"avatar\" | \"inline\" | \"gallery\";\n  /** Shape preset for avatar / dropzone preview. Defaults to `\"rounded\"`. */\n  shape?: \"circle\" | \"square\" | \"rounded\";\n  /** Size preset. Defaults to `\"md\"`. */\n  size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n  /** Accepted mime types, e.g. \"image/*,application/pdf\". */\n  accept?: string;\n  /** Overwrite standard placeholder / dropzone text. */\n  dropzoneText?: string;\n  /** className applied to `<FieldGroup>`. */\n  className?: string;\n  /** Inline width applied to `<FieldGroup>`. */\n  width?: string | number;\n  /** Controls required asterisk visibility. */\n  asterisk?: boolean;\n  /** Text and label overrides. */\n  labels?: {\n    /** Action trigger text (gallery: \"Add file\", inline: \"Attach File\"). */\n    trigger?: React.ReactNode;\n    /** Remove/delete action text. Defaults to \"Remove\". */\n    remove?: React.ReactNode;\n    /** Avatar hover overlay text. Defaults to \"Edit\". */\n    edit?: React.ReactNode;\n    /** Status when empty. Defaults to \"No file selected\". */\n    empty?: React.ReactNode;\n    /** Status with files selected. Receives count. */\n    selected?: (count: number) => React.ReactNode;\n    /** Format support hint. Receives accept string and formatted maxSize. */\n    formatHint?: (accept?: string, maxSize?: string) => React.ReactNode;\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction formatBytes(bytes: number, decimals = 1) {\n  if (bytes === 0) return \"0 Bytes\";\n  const k = 1024;\n  const dm = decimals < 0 ? 0 : decimals;\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + \" \" + sizes[i];\n}\n\nfunction FileTypeIcon({ name }: { name: string }) {\n  const ext = name.split(\".\").pop()?.toLowerCase();\n  const isImage = [\"png\", \"jpg\", \"jpeg\", \"webp\", \"gif\", \"svg\"].includes(\n    ext || \"\",\n  );\n  const isPdf = ext === \"pdf\";\n  const isZip = [\"zip\", \"rar\", \"tar\", \"gz\", \"7z\"].includes(ext || \"\");\n\n  if (isImage) {\n    return (\n      <IconPlaceholder\n        lucide=\"FileImage\"\n        hugeicons=\"Image01Icon\"\n        tabler=\"IconPhoto\"\n        phosphor=\"Image\"\n        remixicon=\"RiImageLine\"\n        className=\"size-5 text-sky-500 shrink-0\"\n      />\n    );\n  }\n  if (isPdf) {\n    return (\n      <IconPlaceholder\n        lucide=\"FileText\"\n        hugeicons=\"File02Icon\"\n        tabler=\"IconFileText\"\n        phosphor=\"FilePdf\"\n        remixicon=\"RiFilePdfLine\"\n        className=\"size-5 text-rose-500 shrink-0\"\n      />\n    );\n  }\n  if (isZip) {\n    return (\n      <IconPlaceholder\n        lucide=\"FileArchive\"\n        hugeicons=\"ZipIcon\"\n        tabler=\"IconZip\"\n        phosphor=\"Archive\"\n        remixicon=\"RiFolderZipLine\"\n        className=\"size-5 text-amber-500 shrink-0\"\n      />\n    );\n  }\n  return (\n    <IconPlaceholder\n      lucide=\"File\"\n      hugeicons=\"File01Icon\"\n      tabler=\"IconFile\"\n      phosphor=\"File\"\n      remixicon=\"RiFileLine\"\n      className=\"size-5 text-muted-foreground shrink-0\"\n    />\n  );\n}\n\nexport function UploadField() {\n  const {\n    fieldApi,\n    field,\n    isDisabled,\n    isRequired,\n    label,\n    placeholder,\n    description,\n    errors,\n    isInvalid,\n    descriptionId,\n    errorId,\n    ariaDescribedBy,\n    handleChange,\n  } = useDataField<UploadFieldDef>();\n\n  const ui = field.ui as UploadUi | undefined;\n  const hasMany = !!field.hasMany;\n  const variant = ui?.variant || \"dropzone\";\n  const shape = ui?.shape || \"rounded\";\n  const size = ui?.size || \"md\";\n\n  const fileInputRef = React.useRef<HTMLInputElement>(null);\n  const [isDragActive, setIsDragActive] = React.useState(false);\n  const [previews, setPreviews] = React.useState<\n    Array<{ name: string; size?: number; url?: string }>\n  >([]);\n\n  const rawValue = fieldApi.state.value;\n\n  // Sync value from state to internal previews array\n  React.useEffect(() => {\n    if (!rawValue) {\n      setPreviews([]);\n      return;\n    }\n\n    const items = Array.isArray(rawValue) ? rawValue : [rawValue];\n    const newPreviews = items.map((item) => {\n      if (typeof window !== \"undefined\" && item instanceof window.File) {\n        return {\n          name: item.name,\n          size: item.size,\n          url: item.type.startsWith(\"image/\")\n            ? URL.createObjectURL(item)\n            : undefined,\n        };\n      }\n      if (typeof item === \"string\") {\n        return {\n          name: item.split(\"/\").pop() || item,\n          url: item,\n        };\n      }\n      return { name: \"Unknown File\" };\n    });\n\n    setPreviews(newPreviews);\n\n    // Cleanup object URLs on unmount/re-sync\n    return () => {\n      newPreviews.forEach((p) => {\n        if (p.url && p.url.startsWith(\"blob:\")) {\n          URL.revokeObjectURL(p.url);\n        }\n      });\n    };\n  }, [rawValue]);\n\n  const onFilesSelected = (fileList: FileList | null) => {\n    if (!fileList || fileList.length === 0) return;\n    const newFiles = Array.from(fileList);\n\n    if (hasMany) {\n      const currentFiles = Array.isArray(rawValue)\n        ? rawValue\n        : rawValue\n          ? [rawValue]\n          : [];\n      handleChange([...currentFiles, ...newFiles]);\n    } else {\n      handleChange(newFiles[0]);\n    }\n  };\n\n  const removeFile = (index: number) => {\n    if (isDisabled) return;\n    if (hasMany && Array.isArray(rawValue)) {\n      const updated = rawValue.filter((_, i) => i !== index);\n      handleChange(updated.length > 0 ? updated : []);\n    } else {\n      handleChange(null as unknown as File | File[] | string | string[]);\n    }\n  };\n\n  const handleDrag = (e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    if (isDisabled) return;\n\n    if (e.type === \"dragenter\" || e.type === \"dragover\") {\n      setIsDragActive(true);\n    } else if (e.type === \"dragleave\") {\n      setIsDragActive(false);\n    }\n  };\n\n  const handleDrop = (e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setIsDragActive(false);\n    if (isDisabled) return;\n\n    if (e.dataTransfer.files && e.dataTransfer.files[0]) {\n      onFilesSelected(e.dataTransfer.files);\n    }\n  };\n\n  const triggerSelect = () => {\n    if (isDisabled) return;\n    fileInputRef.current?.click();\n  };\n\n  const width = ui?.width;\n  const widthStyle = width\n    ? {\n        width: typeof width === \"number\" ? `${width}px` : width,\n        flex: \"0 1 auto\",\n      }\n    : undefined;\n\n  // Inline styling calculations\n  const dropzoneText =\n    ui?.dropzoneText ||\n    placeholder ||\n    (hasMany\n      ? \"Drop files here or click to upload\"\n      : \"Drop file here or click to upload\");\n\n  return (\n    <FieldGroup\n      data-field={fieldApi.name}\n      className={ui?.className}\n      style={widthStyle}\n    >\n      <Field data-invalid={isInvalid} data-disabled={isDisabled}>\n        {label && (\n          <FieldLabel htmlFor={fieldApi.name} className=\"gap-1 items-baseline\">\n            {label}\n            {isRequired && ui?.asterisk !== false ? (\n              <span className=\"text-destructive\">*</span>\n            ) : null}\n          </FieldLabel>\n        )}\n\n        <FieldContent>\n          <div className=\"relative w-full group/upload\">\n            <input\n              ref={fileInputRef}\n              type=\"file\"\n              id={fieldApi.name}\n              name={fieldApi.name}\n              multiple={hasMany}\n              accept={ui?.accept}\n              disabled={isDisabled}\n              onChange={(e) => onFilesSelected(e.target.files)}\n              className=\"hidden\"\n              aria-describedby={ariaDescribedBy}\n              aria-invalid={isInvalid}\n            />\n\n            {/* VARIANT: AVATAR */}\n            {variant === \"avatar\" && (\n              <div className=\"flex flex-col items-center justify-center gap-3\">\n                <div\n                  onClick={triggerSelect}\n                  onDragEnter={handleDrag}\n                  onDragOver={handleDrag}\n                  onDragLeave={handleDrag}\n                  onDrop={handleDrop}\n                  className={cn(\n                    \"relative overflow-hidden cursor-pointer border-2 border-dashed flex items-center justify-center transition-all bg-muted/40 hover:bg-muted/70\",\n                    shape === \"circle\"\n                      ? \"rounded-full\"\n                      : shape === \"square\"\n                        ? \"rounded-none\"\n                        : \"rounded-xl\",\n                    size === \"xs\" && \"size-14\",\n                    size === \"sm\" && \"size-20\",\n                    size === \"md\" && \"size-28\",\n                    size === \"lg\" && \"size-36\",\n                    size === \"xl\" && \"size-48\",\n                    isDragActive\n                      ? \"border-primary bg-primary/5\"\n                      : \"border-muted-foreground/30 hover:border-muted-foreground/50\",\n                    isDisabled &&\n                      \"opacity-60 cursor-not-allowed pointer-events-none\",\n                  )}\n                >\n                  {previews[0]?.url ? (\n                    // eslint-disable-next-line @next/next/no-img-element\n                    <img\n                      src={previews[0].url}\n                      alt=\"Avatar\"\n                      className=\"size-full object-cover\"\n                    />\n                  ) : (\n                    <div className=\"flex flex-col items-center justify-center text-muted-foreground text-center p-2\">\n                      <IconPlaceholder\n                        lucide=\"User\"\n                        hugeicons=\"UserIcon\"\n                        tabler=\"IconUser\"\n                        phosphor=\"User\"\n                        remixicon=\"RiUserLine\"\n                        className={cn(\n                          size === \"xs\" && \"size-5\",\n                          size === \"sm\" && \"size-6\",\n                          size === \"md\" && \"size-8\",\n                          size === \"lg\" && \"size-10\",\n                          size === \"xl\" && \"size-14\",\n                        )}\n                      />\n                    </div>\n                  )}\n\n                  {/* Hover action overlay */}\n                  <div className=\"absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center text-white text-xs font-medium\">\n                    <IconPlaceholder\n                      lucide=\"Camera\"\n                      hugeicons=\"Camera01Icon\"\n                      tabler=\"IconCamera\"\n                      phosphor=\"Camera\"\n                      remixicon=\"RiCameraLine\"\n                      className=\"size-4 mr-1\"\n                    />\n                    {ui?.labels?.edit ?? \"Edit\"}\n                  </div>\n                </div>\n\n                {previews.length > 0 && !isDisabled && (\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"xs\"\n                    onClick={() => removeFile(0)}\n                    className=\"text-muted-foreground hover:text-destructive h-7 px-2\"\n                  >\n                    {ui?.labels?.remove ?? \"Remove\"}\n                  </Button>\n                )}\n              </div>\n            )}\n\n            {/* VARIANT: DROPZONE (DEFAULT) */}\n            {variant === \"dropzone\" && (\n              <div className=\"space-y-3\">\n                <div\n                  onClick={triggerSelect}\n                  onDragEnter={handleDrag}\n                  onDragOver={handleDrag}\n                  onDragLeave={handleDrag}\n                  onDrop={handleDrop}\n                  className={cn(\n                    \"border-2 border-dashed rounded-xl p-6 flex flex-col items-center justify-center gap-3 transition-all cursor-pointer bg-muted/20 hover:bg-muted/40\",\n                    isDragActive\n                      ? \"border-primary bg-primary/5\"\n                      : \"border-muted-foreground/30 hover:border-muted-foreground/50\",\n                    isDisabled &&\n                      \"opacity-60 cursor-not-allowed pointer-events-none\",\n                  )}\n                >\n                  <div className=\"size-10 rounded-lg bg-background border border-border shadow-sm flex items-center justify-center text-muted-foreground\">\n                    <IconPlaceholder\n                      lucide=\"Upload\"\n                      hugeicons=\"Upload04Icon\"\n                      tabler=\"IconUpload\"\n                      phosphor=\"UploadSimple\"\n                      remixicon=\"RiUploadLine\"\n                      className=\"size-5\"\n                    />\n                  </div>\n                  <div className=\"text-center\">\n                    <p className=\"text-sm font-medium\">{dropzoneText}</p>\n                    <p className=\"text-xs text-muted-foreground mt-1\">\n                      {ui?.labels?.formatHint\n                        ? ui.labels.formatHint(\n                            ui.accept,\n                            field.maxSize ? formatBytes(field.maxSize) : undefined,\n                          )\n                        : (\n                            <>\n                              {ui?.accept\n                                ? `Supports: ${ui.accept}`\n                                : \"All formats supported\"}\n                              {field.maxSize\n                                ? ` up to ${formatBytes(field.maxSize)}`\n                                : \"\"}\n                            </>\n                          )}\n                    </p>\n                  </div>\n                </div>\n\n                {/* Render Selected Files List */}\n                {previews.length > 0 && (\n                  <div className=\"border border-border rounded-lg divide-y divide-border overflow-hidden bg-background\">\n                    {previews.map((file, idx) => (\n                      <div\n                        key={idx}\n                        className=\"flex items-center gap-3 p-3 text-sm\"\n                      >\n                        <FileTypeIcon name={file.name} />\n                        <div className=\"flex-1 min-w-0\">\n                          <p className=\"font-medium truncate\">{file.name}</p>\n                          {file.size && (\n                            <p className=\"text-xs text-muted-foreground\">\n                              {formatBytes(file.size)}\n                            </p>\n                          )}\n                        </div>\n                        {!isDisabled && (\n                          <Button\n                            type=\"button\"\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            onClick={() => removeFile(idx)}\n                            className=\"size-8 text-muted-foreground hover:text-destructive shrink-0\"\n                          >\n                            <IconPlaceholder\n                              lucide=\"Trash2\"\n                              hugeicons=\"Delete02Icon\"\n                              tabler=\"IconTrash\"\n                              phosphor=\"Trash\"\n                              remixicon=\"RiDeleteBinLine\"\n                              className=\"size-4\"\n                            />\n                          </Button>\n                        )}\n                      </div>\n                    ))}\n                  </div>\n                )}\n              </div>\n            )}\n\n            {/* VARIANT: INLINE */}\n            {variant === \"inline\" && (\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"flex items-center gap-3\">\n                  <Button\n                    type=\"button\"\n                    variant=\"outline\"\n                    disabled={isDisabled}\n                    onClick={triggerSelect}\n                    className=\"gap-2 shrink-0 border-dashed\"\n                  >\n                    <IconPlaceholder\n                      lucide=\"Upload\"\n                      hugeicons=\"Upload04Icon\"\n                      tabler=\"IconUpload\"\n                      phosphor=\"UploadSimple\"\n                      remixicon=\"RiUploadLine\"\n                      className=\"size-4\"\n                    />\n                    {ui?.labels?.trigger ?? \"Attach File\"}\n                  </Button>\n                  <span className=\"text-xs text-muted-foreground truncate\">\n                    {previews.length === 0\n                      ? (ui?.labels?.empty ?? \"No file selected\")\n                      : (ui?.labels?.selected?.(previews.length) ??\n                        `${previews.length} file(s) attached`)}\n                  </span>\n                </div>\n\n                {previews.length > 0 && (\n                  <div className=\"flex flex-wrap gap-2 mt-1\">\n                    {previews.map((file, idx) => (\n                      <div\n                        key={idx}\n                        className=\"flex items-center gap-2 bg-muted/50 border border-border rounded-full pl-3 pr-1.5 py-1 text-xs font-medium\"\n                      >\n                        <FileTypeIcon name={file.name} />\n                        <span className=\"truncate max-w-30\">{file.name}</span>\n                        {!isDisabled && (\n                          <button\n                            type=\"button\"\n                            onClick={() => removeFile(idx)}\n                            className=\"size-5 rounded-full hover:bg-muted-foreground/20 flex items-center justify-center text-muted-foreground hover:text-foreground shrink-0\"\n                          >\n                            <IconPlaceholder\n                              lucide=\"X\"\n                              hugeicons=\"Cancel01Icon\"\n                              tabler=\"IconX\"\n                              phosphor=\"X\"\n                              remixicon=\"RiCloseLine\"\n                              className=\"size-3.5\"\n                            />\n                          </button>\n                        )}\n                      </div>\n                    ))}\n                  </div>\n                )}\n              </div>\n            )}\n\n            {/* VARIANT: GALLERY */}\n            {variant === \"gallery\" && (\n              <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3\">\n                {previews.map((file, idx) => (\n                  <div\n                    key={idx}\n                    className={cn(\n                      \"relative group/gallery aspect-square border border-border flex items-center justify-center overflow-hidden bg-muted/10\",\n                      shape === \"circle\"\n                        ? \"rounded-full\"\n                        : shape === \"square\"\n                          ? \"rounded-none\"\n                          : \"rounded-xl\",\n                    )}\n                  >\n                    {file.url ? (\n                      // eslint-disable-next-line @next/next/no-img-element\n                      <img\n                        src={file.url}\n                        alt={file.name}\n                        className=\"size-full object-cover transition-transform group-hover/gallery:scale-105\"\n                      />\n                    ) : (\n                      <div className=\"flex flex-col items-center justify-center text-muted-foreground p-3 text-center\">\n                        <FileTypeIcon name={file.name} />\n                        <span className=\"text-[10px] mt-1 truncate max-w-20\">\n                          {file.name}\n                        </span>\n                      </div>\n                    )}\n\n                    {/* Delete overlay */}\n                    {!isDisabled && (\n                      <div className=\"absolute inset-0 bg-black/40 opacity-0 group-hover/gallery:opacity-100 transition-opacity flex items-center justify-center\">\n                        <Button\n                          type=\"button\"\n                          variant=\"destructive\"\n                          size=\"icon\"\n                          onClick={() => removeFile(idx)}\n                          className=\"size-8\"\n                        >\n                          <IconPlaceholder\n                            lucide=\"Trash2\"\n                            hugeicons=\"Delete02Icon\"\n                            tabler=\"IconTrash\"\n                            phosphor=\"Trash\"\n                            remixicon=\"RiDeleteBinLine\"\n                            className=\"size-4\"\n                          />\n                        </Button>\n                      </div>\n                    )}\n                  </div>\n                ))}\n\n                {/* Add Box */}\n                {(!hasMany && previews.length === 0) ||\n                (hasMany && (!field.max || previews.length < field.max)) ? (\n                  <div\n                    onClick={triggerSelect}\n                    className={cn(\n                      \"aspect-square border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50 transition-all flex flex-col items-center justify-center gap-1.5 cursor-pointer bg-muted/10 hover:bg-muted/30\",\n                      shape === \"circle\"\n                        ? \"rounded-full\"\n                        : shape === \"square\"\n                          ? \"rounded-none\"\n                          : \"rounded-xl\",\n                      isDisabled &&\n                        \"opacity-60 cursor-not-allowed pointer-events-none\",\n                    )}\n                  >\n                    <IconPlaceholder\n                      lucide=\"Plus\"\n                      hugeicons=\"PlusSignIcon\"\n                      tabler=\"IconPlus\"\n                      phosphor=\"Plus\"\n                      remixicon=\"RiAddLine\"\n                      className=\"size-5 text-muted-foreground\"\n                    />\n                    <span className=\"text-xs text-muted-foreground font-medium\">\n                      {ui?.labels?.trigger ?? \"Add file\"}\n                    </span>\n                  </div>\n                ) : null}\n              </div>\n            )}\n          </div>\n        </FieldContent>\n\n        {description && !isInvalid && (\n          <FieldDescription id={descriptionId}>{description}</FieldDescription>\n        )}\n\n        {isInvalid && <FieldError id={errorId} errors={errors} />}\n      </Field>\n    </FieldGroup>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/buzzform/fields/upload.tsx"
    }
  ],
  "type": "registry:ui"
}