{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image",
  "type": "registry:component",
  "title": "Image",
  "description": "Display image parts with preview, loading states, and a fullscreen dialog.",
  "dependencies": [
    "@assistant-ui/react",
    "lucide-react",
    "class-variance-authority"
  ],
  "registryDependencies": [],
  "files": [
    {
      "type": "registry:component",
      "path": "components/assistant-ui/image.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useState,\n  useEffect,\n  useRef,\n  type PropsWithChildren,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport {\n  CopyIcon,\n  DownloadIcon,\n  ImageIcon,\n  ImageOffIcon,\n  Loader2Icon,\n  RefreshCwIcon,\n  ShieldAlertIcon,\n} from \"lucide-react\";\nimport type {\n  ImageMessagePart,\n  ImageMessagePartComponent,\n} from \"@assistant-ui/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst extensionForMimeType = (mimeType?: string): string => {\n  switch (mimeType) {\n    case \"image/png\":\n      return \"png\";\n    case \"image/jpeg\":\n    case \"image/jpg\":\n      return \"jpg\";\n    case \"image/webp\":\n      return \"webp\";\n    case \"image/gif\":\n      return \"gif\";\n    case \"image/svg+xml\":\n      return \"svg\";\n    default:\n      return \"png\";\n  }\n};\n\nconst dataUriToBlob = (dataUri: string): Blob => {\n  const [meta, data] = dataUri.split(\",\");\n  const mime =\n    meta?.match(/data:([^;]+)/i)?.[1]?.toLowerCase() ??\n    \"application/octet-stream\";\n  if (!/;base64/i.test(meta ?? \"\")) {\n    return new Blob([decodeURIComponent(data ?? \"\")], { type: mime });\n  }\n  const bytes = atob(data ?? \"\");\n  const arr = new Uint8Array(bytes.length);\n  for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);\n  return new Blob([arr], { type: mime });\n};\n\nconst mimeFromImage = (image: string): string | undefined =>\n  image.match(/^data:([^;,]+)/i)?.[1]?.toLowerCase();\n\nconst downloadImagePart = (\n  part: Pick<ImageMessagePart, \"image\" | \"filename\">,\n): void => {\n  if (typeof document === \"undefined\") return;\n  const ext = extensionForMimeType(mimeFromImage(part.image));\n  const filename = part.filename ?? `image.${ext}`;\n  const isDataUri = /^data:/i.test(part.image);\n  const objectUrl = isDataUri\n    ? URL.createObjectURL(dataUriToBlob(part.image))\n    : null;\n  const href = objectUrl ?? part.image;\n  const a = document.createElement(\"a\");\n  a.href = href;\n  a.download = filename;\n  a.rel = \"noopener\";\n  document.body.appendChild(a);\n  a.click();\n  document.body.removeChild(a);\n  if (objectUrl) setTimeout(() => URL.revokeObjectURL(objectUrl), 40_000);\n};\n\nconst copyImagePart = async (\n  part: Pick<ImageMessagePart, \"image\">,\n): Promise<void> => {\n  if (\n    typeof navigator === \"undefined\" ||\n    !navigator.clipboard ||\n    typeof ClipboardItem === \"undefined\"\n  ) {\n    throw new Error(\"Clipboard API is not available in this environment.\");\n  }\n  const blob = /^data:/i.test(part.image)\n    ? dataUriToBlob(part.image)\n    : await fetch(part.image).then((r) => r.blob());\n  const mime = mimeFromImage(part.image) ?? blob.type ?? \"image/png\";\n  await navigator.clipboard.write([new ClipboardItem({ [mime]: blob })]);\n};\n\nconst imageVariants = cva(\n  \"aui-image-root relative overflow-hidden rounded-lg\",\n  {\n    variants: {\n      variant: {\n        outline: \"border-border border\",\n        ghost: \"\",\n        muted: \"bg-muted/50\",\n      },\n      size: {\n        sm: \"max-w-64\",\n        default: \"max-w-96\",\n        lg: \"max-w-[512px]\",\n        full: \"w-full\",\n      },\n    },\n    defaultVariants: {\n      variant: \"outline\",\n      size: \"default\",\n    },\n  },\n);\n\nexport type ImageRootProps = React.ComponentProps<\"div\"> &\n  VariantProps<typeof imageVariants>;\n\nfunction ImageRoot({\n  className,\n  variant,\n  size,\n  children,\n  ...props\n}: ImageRootProps) {\n  return (\n    <div\n      data-slot=\"image-root\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(imageVariants({ variant, size, className }))}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\ntype ImagePreviewProps = Omit<React.ComponentProps<\"img\">, \"children\"> & {\n  containerClassName?: string;\n};\n\nfunction ImagePreview({\n  className,\n  containerClassName,\n  onLoad,\n  onError,\n  alt = \"Image content\",\n  src,\n  ...props\n}: ImagePreviewProps) {\n  const imgRef = useRef<HTMLImageElement>(null);\n  const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);\n  const [errorSrc, setErrorSrc] = useState<string | undefined>(undefined);\n\n  const loaded = loadedSrc === src;\n  const error = errorSrc === src;\n\n  useEffect(() => {\n    if (\n      typeof src === \"string\" &&\n      imgRef.current?.complete &&\n      imgRef.current.naturalWidth > 0\n    ) {\n      setLoadedSrc(src);\n    }\n  }, [src]);\n\n  return (\n    <div\n      data-slot=\"image-preview\"\n      className={cn(\"relative min-h-32\", containerClassName)}\n    >\n      {!loaded && !error && (\n        <div\n          data-slot=\"image-preview-loading\"\n          className=\"bg-muted/50 absolute inset-0 flex items-center justify-center\"\n        >\n          <ImageIcon className=\"text-muted-foreground size-8 animate-pulse\" />\n        </div>\n      )}\n      {error ? (\n        <div\n          data-slot=\"image-preview-error\"\n          className=\"bg-muted/50 flex min-h-32 items-center justify-center p-4\"\n        >\n          <ImageOffIcon className=\"text-muted-foreground size-8\" />\n        </div>\n      ) : (\n        <img\n          ref={imgRef}\n          src={src}\n          alt={alt}\n          className={cn(\n            \"block h-auto w-full object-contain\",\n            !loaded && \"invisible\",\n            className,\n          )}\n          onLoad={(e) => {\n            if (typeof src === \"string\") setLoadedSrc(src);\n            onLoad?.(e);\n          }}\n          onError={(e) => {\n            if (typeof src === \"string\") setErrorSrc(src);\n            onError?.(e);\n          }}\n          {...props}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction ImageFilename({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  if (!children) return null;\n\n  return (\n    <span\n      data-slot=\"image-filename\"\n      className={cn(\n        \"text-muted-foreground block truncate px-2 py-1.5 text-xs\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </span>\n  );\n}\n\ntype ImageZoomProps = PropsWithChildren<{\n  src: string;\n  alt?: string;\n}>;\n\nfunction ImageZoom({ src, alt = \"Image preview\", children }: ImageZoomProps) {\n  const [isMounted, setIsMounted] = useState(false);\n  const [isOpen, setIsOpen] = useState(false);\n\n  useEffect(() => {\n    setIsMounted(true);\n  }, []);\n\n  const handleOpen = () => setIsOpen(true);\n  const handleClose = () => setIsOpen(false);\n\n  useEffect(() => {\n    if (!isOpen) return;\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setIsOpen(false);\n    };\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, [isOpen]);\n\n  useEffect(() => {\n    if (!isOpen) return;\n    const originalOverflow = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = originalOverflow;\n    };\n  }, [isOpen]);\n\n  return (\n    <>\n      <div\n        onClick={handleOpen}\n        onKeyDown={(e) => e.key === \"Enter\" && handleOpen()}\n        role=\"button\"\n        tabIndex={0}\n        className=\"aui-image-zoom-trigger cursor-zoom-in\"\n        aria-label=\"Click to zoom image\"\n      >\n        {children}\n      </div>\n      {isMounted &&\n        isOpen &&\n        createPortal(\n          <div\n            data-slot=\"image-zoom-overlay\"\n            role=\"button\"\n            tabIndex={0}\n            className=\"aui-image-zoom-overlay fade-in animate-in fixed inset-0 z-50 flex items-center justify-center bg-black/80 duration-200\"\n            onClick={handleClose}\n            onKeyDown={(e) => e.key === \"Enter\" && handleClose()}\n            aria-label=\"Close zoomed image\"\n          >\n            <img\n              data-slot=\"image-zoom-content\"\n              src={src}\n              alt={alt}\n              className=\"aui-image-zoom-content fade-in zoom-in-95 animate-in max-h-[90vh] max-w-[90vw] cursor-zoom-out object-contain duration-200\"\n              onClick={(e) => {\n                e.stopPropagation();\n                handleClose();\n              }}\n            />\n          </div>,\n          document.body,\n        )}\n    </>\n  );\n}\n\nfunction ImageGenerating({ className }: { className?: string }) {\n  return (\n    <div\n      data-slot=\"image-generating\"\n      className={cn(\n        \"bg-muted/50 flex min-h-32 items-center justify-center p-4\",\n        className,\n      )}\n    >\n      <Loader2Icon className=\"text-muted-foreground size-8 animate-spin\" />\n      <span className=\"sr-only\">Generating image…</span>\n    </div>\n  );\n}\n\nfunction ImageContentFilterError({\n  className,\n  reason,\n}: {\n  className?: string;\n  reason?: string;\n}) {\n  return (\n    <div\n      data-slot=\"image-content-filter-error\"\n      className={cn(\n        \"bg-muted/50 flex min-h-32 flex-col items-center justify-center gap-2 p-4 text-center\",\n        className,\n      )}\n    >\n      <ShieldAlertIcon className=\"text-muted-foreground size-8\" />\n      <p className=\"text-sm font-medium\">Image could not be generated</p>\n      {reason && <p className=\"text-muted-foreground text-xs\">{reason}</p>}\n    </div>\n  );\n}\n\nexport type ImageActionsProps = {\n  part: ImageMessagePart;\n  /**\n   * Wire to your own generation call to show a regenerate button. The button\n   * renders only when this is set and the part carries a `prompt`.\n   */\n  onRegenerate?: () => void | Promise<void>;\n  className?: string;\n};\n\nfunction RegenerateButton({\n  onRegenerate,\n}: {\n  onRegenerate: () => void | Promise<void>;\n}) {\n  const [isRegenerating, setIsRegenerating] = useState(false);\n  return (\n    <button\n      type=\"button\"\n      onClick={async () => {\n        setIsRegenerating(true);\n        try {\n          await onRegenerate();\n        } finally {\n          setIsRegenerating(false);\n        }\n      }}\n      disabled={isRegenerating}\n      data-slot=\"image-regenerate\"\n      aria-label=\"Regenerate image\"\n      className=\"hover:bg-muted inline-flex size-7 items-center justify-center rounded disabled:opacity-50\"\n    >\n      <RefreshCwIcon\n        className={cn(\"size-4\", isRegenerating && \"animate-spin\")}\n      />\n    </button>\n  );\n}\n\nfunction ImageActions({ part, onRegenerate, className }: ImageActionsProps) {\n  return (\n    <div\n      data-slot=\"image-actions\"\n      className={cn(\"flex items-center gap-1 p-1\", className)}\n    >\n      <button\n        type=\"button\"\n        onClick={() => downloadImagePart(part)}\n        data-slot=\"image-download\"\n        aria-label=\"Download image\"\n        className=\"hover:bg-muted inline-flex size-7 items-center justify-center rounded\"\n      >\n        <DownloadIcon className=\"size-4\" />\n      </button>\n      <button\n        type=\"button\"\n        onClick={() => {\n          copyImagePart(part).catch(() => {});\n        }}\n        data-slot=\"image-copy\"\n        aria-label=\"Copy image\"\n        className=\"hover:bg-muted inline-flex size-7 items-center justify-center rounded\"\n      >\n        <CopyIcon className=\"size-4\" />\n      </button>\n      {onRegenerate && <RegenerateButton onRegenerate={onRegenerate} />}\n    </div>\n  );\n}\n\nconst ImageImpl: ImageMessagePartComponent = (props) => {\n  const { image, filename, status } = props;\n\n  if (status?.type === \"running\") {\n    return (\n      <ImageRoot>\n        <ImageGenerating />\n        <ImageFilename>{filename}</ImageFilename>\n      </ImageRoot>\n    );\n  }\n\n  if (status?.type === \"incomplete\" && status.reason === \"content-filter\") {\n    return (\n      <ImageRoot>\n        <ImageContentFilterError reason=\"The provider blocked this image.\" />\n      </ImageRoot>\n    );\n  }\n\n  return (\n    <ImageRoot>\n      <ImageZoom src={image} alt={filename || \"Image content\"}>\n        <ImagePreview src={image} alt={filename || \"Image content\"} />\n      </ImageZoom>\n      <ImageFilename>{filename}</ImageFilename>\n    </ImageRoot>\n  );\n};\n\nconst Image = memo(ImageImpl) as unknown as ImageMessagePartComponent & {\n  Root: typeof ImageRoot;\n  Preview: typeof ImagePreview;\n  Filename: typeof ImageFilename;\n  Zoom: typeof ImageZoom;\n  Actions: typeof ImageActions;\n  Generating: typeof ImageGenerating;\n  ContentFilterError: typeof ImageContentFilterError;\n};\n\nImage.displayName = \"Image\";\nImage.Root = ImageRoot;\nImage.Preview = ImagePreview;\nImage.Filename = ImageFilename;\nImage.Zoom = ImageZoom;\nImage.Actions = ImageActions;\nImage.Generating = ImageGenerating;\nImage.ContentFilterError = ImageContentFilterError;\n\nexport {\n  Image,\n  ImageRoot,\n  ImagePreview,\n  ImageFilename,\n  ImageZoom,\n  ImageActions,\n  ImageGenerating,\n  ImageContentFilterError,\n  imageVariants,\n};\n"
    }
  ]
}