{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-upload",
  "type": "registry:component",
  "title": "Image upload",
  "description": "Picture field for avatars and logos: preview, upload or replace, remove, and a crop dialog (drag and zoom) that encodes a square PNG in the browser.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@wibo/utils",
    "@wibo/avatar",
    "@wibo/button",
    "@wibo/dialog",
    "@wibo/confirm-dialog",
    "@wibo/square-image"
  ],
  "files": [
    {
      "path": "src/patterns/image-upload.tsx",
      "type": "registry:component",
      "content": "import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { ImageUp, Trash2 } from \"lucide-react\";\nimport { Avatar, AvatarFallback, AvatarImage, type AvatarTone } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { cn } from \"@/lib/utils\";\nimport { ConfirmDialog } from \"@/components/confirm-dialog\";\nimport {\n  MAX_SQUARE_ZOOM,\n  SQUARE_IMAGE_SIZE,\n  SQUARE_IMAGE_TYPES,\n  centredCrop,\n  clampCrop,\n  cropRect,\n  encodeSquarePng,\n  panCrop,\n  type SquareCrop,\n} from \"@/lib/square-image\";\n\ninterface ImageUploadLabels {\n  upload: string;\n  replace: string;\n  remove: string;\n  cropTitle: string;\n  cropDescription: string;\n  zoom: string;\n  position: string;\n  save: string;\n  cancel: string;\n  /** Shown when the file is not a PNG, JPEG or WebP picture, or cannot be read. */\n  invalid: string;\n  /** Status line after a stored or removed picture; nothing is said without them. */\n  saved?: string;\n  removed?: string;\n}\n\nconst defaultLabels: ImageUploadLabels = {\n  upload: \"Upload picture\",\n  replace: \"Replace\",\n  remove: \"Remove\",\n  cropTitle: \"Crop picture\",\n  cropDescription: \"Drag to choose the square, zoom with the slider.\",\n  zoom: \"Zoom\",\n  position: \"Picture position\",\n  save: \"Save\",\n  cancel: \"Cancel\",\n  invalid: \"Choose a PNG, JPEG or WebP picture.\",\n};\n\ninterface ImageUploadProps {\n  /** Label of the field, for example \"Picture\" or \"Logo\". */\n  label: ReactNode;\n  /** Keeps the label for screen readers only, when a heading above already names the field. */\n  hideLabel?: boolean;\n  description?: ReactNode;\n  /** Current picture URL; null shows `fallback`. */\n  value: string | null;\n  /** Initials or mark shown without a picture. */\n  fallback: string;\n  tone?: AvatarTone;\n  shape?: \"square\" | \"circle\";\n  /** Stores the cropped square PNG. Resolves to an error message, or null when stored. */\n  onUpload: (png: Blob) => Promise<string | null>;\n  /** Removes the picture. Resolves to an error message, or null. Without it there is no remove button. */\n  onRemove?: () => Promise<string | null>;\n  /** Asks in a `ConfirmDialog` before removing (rule 11: every destructive act confirms). */\n  confirmRemove?: { title: ReactNode; description?: ReactNode; confirmLabel: string; cancelLabel?: string; closeLabel?: string };\n  /** Side of the PNG in pixels (256 by default). */\n  size?: number;\n  labels?: Partial<ImageUploadLabels>;\n  disabled?: boolean;\n  className?: string;\n}\n\ninterface Picked {\n  url: string;\n  image: HTMLImageElement;\n}\n\n/**\n * A picture field (avatar, logo): the current picture, \"Upload\" or\n * \"Replace\", \"Remove\" (confirmed with `confirmRemove`). A picked file opens\n * a crop dialog where the person drags the square and zooms; saving encodes\n * it in the browser as a square PNG (`@wibo/ui/lib/square-image`) and hands\n * it to `onUpload`. The outcome is said on a status line under the field,\n * not in a toast. Holds state: import it by path\n * (`@wibo/ui/patterns/image-upload`) from client components.\n */\nfunction ImageUpload({\n  label,\n  hideLabel = false,\n  description,\n  value,\n  fallback,\n  tone = \"neutral\",\n  shape = \"square\",\n  onUpload,\n  onRemove,\n  confirmRemove,\n  size = SQUARE_IMAGE_SIZE,\n  labels: labelOverrides,\n  disabled = false,\n  className,\n}: ImageUploadProps) {\n  const labels = { ...defaultLabels, ...labelOverrides };\n  const input = useRef<HTMLInputElement>(null);\n  const id = useId();\n  const [picked, setPicked] = useState<Picked | null>(null);\n  const [crop, setCrop] = useState<SquareCrop>({ centerX: 0, centerY: 0, zoom: 1 });\n  const [busy, setBusy] = useState<\"upload\" | \"remove\" | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [cropError, setCropError] = useState<string | null>(null);\n  const [preview, setPreview] = useState<string | null>(value);\n  const [said, setSaid] = useState<string | null>(null);\n  const [confirming, setConfirming] = useState(false);\n  // A new value from the server replaces the local preview.\n  const [lastValue, setLastValue] = useState(value);\n  if (value !== lastValue) {\n    setLastValue(value);\n    setPreview(value);\n  }\n\n  useEffect(() => () => {\n    if (picked) URL.revokeObjectURL(picked.url);\n  }, [picked]);\n\n  const choose = (file: File) => {\n    setError(null);\n    if (!SQUARE_IMAGE_TYPES.includes(file.type)) {\n      setError(labels.invalid);\n      return;\n    }\n    const url = URL.createObjectURL(file);\n    const image = new Image();\n    image.onload = () => {\n      setCrop(centredCrop(image.naturalWidth, image.naturalHeight));\n      setCropError(null);\n      setPicked({ url, image });\n    };\n    image.onerror = () => {\n      URL.revokeObjectURL(url);\n      setError(labels.invalid);\n    };\n    image.src = url;\n  };\n\n  const save = async () => {\n    if (!picked) return;\n    setBusy(\"upload\");\n    setCropError(null);\n    try {\n      const png = await encodeSquarePng(picked.image, crop, size);\n      const failure = await onUpload(png);\n      if (failure) {\n        setCropError(failure);\n        return;\n      }\n      setPreview(URL.createObjectURL(png));\n      setSaid(labels.saved ?? null);\n      setPicked(null);\n    } catch {\n      setCropError(labels.invalid);\n    } finally {\n      setBusy(null);\n    }\n  };\n\n  const remove = async () => {\n    if (!onRemove) return;\n    setBusy(\"remove\");\n    setError(null);\n    setSaid(null);\n    try {\n      const failure = await onRemove();\n      if (failure) setError(failure);\n      else {\n        setPreview(null);\n        setSaid(labels.removed ?? null);\n      }\n    } finally {\n      setBusy(null);\n    }\n  };\n\n  return (\n    <div data-slot=\"image-upload\" className={cn(\"grid gap-1.5\", className)}>\n      <span id={`${id}-label`} className={cn(\"text-sm leading-none font-medium text-foreground\", hideLabel && \"sr-only\")}>\n        {label}\n      </span>\n      <div className=\"flex flex-wrap items-center gap-3\">\n        <Avatar size=\"lg\" tone={tone} shape={shape} className=\"size-14 text-base\">\n          {preview ? <AvatarImage src={preview} alt=\"\" /> : null}\n          <AvatarFallback>{fallback}</AvatarFallback>\n        </Avatar>\n        <input\n          ref={input}\n          type=\"file\"\n          accept={SQUARE_IMAGE_TYPES.join(\",\")}\n          className=\"sr-only\"\n          tabIndex={-1}\n          aria-labelledby={`${id}-label`}\n          onChange={(event) => {\n            const file = event.target.files?.[0];\n            event.target.value = \"\";\n            if (file) choose(file);\n          }}\n        />\n        <div className=\"flex flex-wrap gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            disabled={disabled || busy !== null}\n            loading={busy === \"upload\" && !picked}\n            onClick={() => input.current?.click()}\n          >\n            <ImageUp aria-hidden=\"true\" />\n            {preview ? labels.replace : labels.upload}\n          </Button>\n          {preview && onRemove ? (\n            <Button type=\"button\" variant=\"ghost\" size=\"sm\" disabled={disabled || busy !== null} loading={busy === \"remove\"} onClick={() => (confirmRemove ? setConfirming(true) : void remove())}>\n              <Trash2 aria-hidden=\"true\" />\n              {labels.remove}\n            </Button>\n          ) : null}\n        </div>\n      </div>\n      {description ? <p className=\"text-xs text-muted-foreground\">{description}</p> : null}\n      <p role=\"status\" className=\"text-xs text-muted-foreground empty:hidden\">\n        {said}\n      </p>\n      {error ? (\n        <p role=\"alert\" className=\"text-xs font-medium text-danger\">\n          {error}\n        </p>\n      ) : null}\n      {confirmRemove && onRemove ? (\n        <ConfirmDialog\n          open={confirming}\n          onOpenChange={setConfirming}\n          title={confirmRemove.title}\n          description={confirmRemove.description}\n          confirmLabel={confirmRemove.confirmLabel}\n          cancelLabel={confirmRemove.cancelLabel}\n          closeLabel={confirmRemove.closeLabel}\n          onConfirm={async () => {\n            setError(null);\n            setSaid(null);\n            const failure = await onRemove();\n            if (failure) throw new Error(failure);\n            setPreview(null);\n            setSaid(labels.removed ?? null);\n          }}\n        />\n      ) : null}\n      <Dialog open={picked !== null} onOpenChange={(open) => (!open && busy === null ? setPicked(null) : undefined)}>\n        <DialogContent closeLabel={labels.cancel} className=\"sm:max-w-sm\">\n          <DialogHeader>\n            <DialogTitle>{labels.cropTitle}</DialogTitle>\n            <DialogDescription>{labels.cropDescription}</DialogDescription>\n          </DialogHeader>\n          {picked ? <CropArea picked={picked} crop={crop} onCrop={setCrop} shape={shape} positionLabel={labels.position} zoomLabel={labels.zoom} /> : null}\n          {cropError ? (\n            <p role=\"alert\" className=\"text-xs font-medium text-danger\">\n              {cropError}\n            </p>\n          ) : null}\n          <DialogFooter>\n            <Button type=\"button\" variant=\"outline\" disabled={busy !== null} onClick={() => setPicked(null)}>\n              {labels.cancel}\n            </Button>\n            <Button type=\"button\" loading={busy === \"upload\"} onClick={() => void save()}>\n              {labels.save}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n\n/** The square viewport of the crop dialog: drag or arrow keys to move, the slider or + and - to zoom. */\nfunction CropArea({\n  picked,\n  crop,\n  onCrop,\n  shape,\n  positionLabel,\n  zoomLabel,\n}: {\n  picked: Picked;\n  crop: SquareCrop;\n  onCrop: (crop: SquareCrop) => void;\n  shape: \"square\" | \"circle\";\n  positionLabel: string;\n  zoomLabel: string;\n}) {\n  const viewport = useRef<HTMLDivElement>(null);\n  const drag = useRef<{ x: number; y: number; pointer: number } | null>(null);\n  const zoomId = useId();\n  const width = picked.image.naturalWidth;\n  const height = picked.image.naturalHeight;\n  const rect = cropRect(width, height, crop);\n  // The preview is drawn in percentages of the viewport, so it does not need measuring.\n  const scale = 100 / rect.side;\n  const side = () => viewport.current?.clientWidth || 240;\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = { x: event.clientX, y: event.clientY, pointer: event.pointerId };\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const start = drag.current;\n    if (!start || start.pointer !== event.pointerId) return;\n    onCrop(panCrop(width, height, crop, event.clientX - start.x, event.clientY - start.y, side()));\n    drag.current = { ...start, x: event.clientX, y: event.clientY };\n  };\n  const onPointerUp = () => {\n    drag.current = null;\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const step = event.shiftKey ? 40 : 10;\n    const moves: Record<string, [number, number]> = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };\n    const move = moves[event.key];\n    if (move) {\n      event.preventDefault();\n      onCrop(panCrop(width, height, crop, move[0], move[1], side()));\n    } else if (event.key === \"+\" || event.key === \"=\" || event.key === \"-\") {\n      event.preventDefault();\n      onCrop(clampCrop(width, height, { ...crop, zoom: crop.zoom + (event.key === \"-\" ? -0.25 : 0.25) }));\n    }\n  };\n\n  return (\n    <div className=\"grid gap-4\">\n      <div\n        ref={viewport}\n        role=\"img\"\n        aria-label={positionLabel}\n        tabIndex={0}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onKeyDown={onKeyDown}\n        className={cn(\n          \"relative mx-auto aspect-square w-full max-w-60 cursor-grab touch-none overflow-hidden bg-muted outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50 active:cursor-grabbing\",\n          shape === \"circle\" ? \"rounded-full\" : \"rounded-xl\",\n        )}\n      >\n        {/* A local object URL of the picked file: nothing to optimise. */}\n        <img\n          src={picked.url}\n          alt=\"\"\n          draggable={false}\n          className=\"pointer-events-none absolute top-0 left-0 max-w-none origin-top-left\"\n          style={{\n            width: `${width * scale}%`,\n            height: `${height * scale}%`,\n            transform: `translate(${-rect.x * (100 / width)}%, ${-rect.y * (100 / height)}%)`,\n          }}\n        />\n        <span aria-hidden=\"true\" className={cn(\"pointer-events-none absolute inset-0 ring-1 ring-foreground/15 ring-inset\", shape === \"circle\" ? \"rounded-full\" : \"rounded-xl\")} />\n      </div>\n      <div className=\"grid gap-1.5\">\n        <label htmlFor={zoomId} className=\"text-xs text-muted-foreground\">\n          {zoomLabel}\n        </label>\n        <input\n          id={zoomId}\n          type=\"range\"\n          min={1}\n          max={MAX_SQUARE_ZOOM}\n          step={0.01}\n          value={crop.zoom}\n          onChange={(event) => onCrop(clampCrop(width, height, { ...crop, zoom: Number(event.target.value) }))}\n          className=\"w-full accent-accent\"\n        />\n      </div>\n    </div>\n  );\n}\n\nexport { ImageUpload };\nexport type { ImageUploadLabels, ImageUploadProps };\n"
    }
  ]
}
