{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image","title":"Image","description":"Image renderer for URLs, base64, and byte payloads with preview, loader, lightbox, and action slots","dependencies":["@radix-ui/react-slot","@radix-ui/react-dialog"],"registryDependencies":["tooltip","kbd"],"files":[{"path":"components/nexus-ui/image.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { Slot } from \"@radix-ui/react-slot\";\n\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { cn } from \"@/lib/utils\";\n\ntype ImageData = {\n  src?: string;\n  base64?: string;\n  uint8Array?: Uint8Array;\n  mediaType?: string;\n};\n\nfunction resolveBase64MediaType(base64: string, fallback: string) {\n  const trimmed = base64.trim();\n\n  if (trimmed.startsWith(\"data:\")) {\n    return /^data:([^;,]+)/i.exec(trimmed)?.[1] ?? fallback;\n  }\n\n  const normalized = trimmed.replace(/\\s+/g, \"\").slice(0, 120);\n\n  if (normalized.startsWith(\"iVBORw0KGgo\")) return \"image/png\";\n  if (normalized.startsWith(\"/9j/\")) return \"image/jpeg\";\n  if (normalized.startsWith(\"R0lGOD\")) return \"image/gif\";\n  if (normalized.startsWith(\"UklGR\") && normalized.includes(\"V0VCUA\")) {\n    return \"image/webp\";\n  }\n  if (normalized.startsWith(\"PHN2Zy\") || normalized.startsWith(\"PD94bWwg\")) {\n    return \"image/svg+xml\";\n  }\n\n  return fallback;\n}\n\nfunction toDataUrl(base64: string, mediaType: string) {\n  const trimmed = base64.trim();\n  if (trimmed.startsWith(\"data:\")) return trimmed;\n  return `data:${mediaType};base64,${trimmed}`;\n}\n\nfunction uint8ArrayToBase64(bytes: Uint8Array) {\n  let binary = \"\";\n  const chunkSize = 0x8000;\n  for (let i = 0; i < bytes.length; i += chunkSize) {\n    const chunk = bytes.subarray(i, i + chunkSize);\n    binary += String.fromCharCode(...chunk);\n  }\n  return btoa(binary);\n}\n\ntype ImageContextValue = {\n  src?: string;\n  alt: string;\n  mediaType?: string;\n  hasError: boolean;\n  setHasError: (value: boolean) => void;\n};\n\nconst ImageContext = React.createContext<ImageContextValue | null>(null);\n\nfunction useImageContext(component: string) {\n  const ctx = React.useContext(ImageContext);\n  if (!ctx) {\n    throw new Error(`${component} must be used within <Image>`);\n  }\n  return ctx;\n}\n\nexport type ImageProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> &\n  Omit<React.ComponentProps<typeof DialogPrimitive.Root>, \"children\"> &\n  ImageData & {\n    alt?: string;\n    children?: React.ReactNode;\n  };\n\nfunction Image({\n  src,\n  base64,\n  uint8Array,\n  mediaType,\n  open,\n  defaultOpen,\n  onOpenChange,\n  modal,\n  alt = \"\",\n  className,\n  children,\n  ...props\n}: ImageProps) {\n  const hasUint8BlobSource =\n    !base64 && uint8Array != null && uint8Array.length > 0;\n  const { resolvedMediaType, resolvedSrc } = React.useMemo(() => {\n    const nextResolvedMediaType = base64\n      ? resolveBase64MediaType(base64, mediaType ?? \"image/png\")\n      : (mediaType ?? \"image/png\");\n\n    const nextResolvedSrc = base64\n      ? toDataUrl(base64, nextResolvedMediaType)\n      : hasUint8BlobSource\n        ? toDataUrl(uint8ArrayToBase64(uint8Array), nextResolvedMediaType)\n        : src;\n\n    return {\n      resolvedMediaType: nextResolvedMediaType,\n      resolvedSrc: nextResolvedSrc,\n    };\n  }, [base64, mediaType, src, hasUint8BlobSource, uint8Array]);\n\n  const [hasError, setHasError] = React.useState(false);\n\n  const contextValue = React.useMemo<ImageContextValue>(\n    () => ({\n      src: resolvedSrc,\n      alt,\n      mediaType: resolvedMediaType,\n      hasError,\n      setHasError,\n    }),\n    [resolvedSrc, alt, resolvedMediaType, hasError],\n  );\n\n  return (\n    <ImageContext.Provider value={contextValue}>\n      <DialogPrimitive.Root\n        open={open}\n        defaultOpen={defaultOpen}\n        onOpenChange={onOpenChange}\n        modal={modal}\n      >\n        <div\n          data-slot=\"image\"\n          className={cn(\n            \"group/image relative inline-flex aspect-auto max-w-full min-w-64 flex-col overflow-hidden rounded-2xl border dark:border-muted\",\n            className,\n          )}\n          {...props}\n        >\n          {children ?? <ImagePreview />}\n        </div>\n      </DialogPrimitive.Root>\n    </ImageContext.Provider>\n  );\n}\n\nexport type ImagePreviewProps = React.ImgHTMLAttributes<HTMLImageElement>;\n\nfunction ImagePreview({\n  className,\n  src: srcProp,\n  alt: altProp,\n  onLoad,\n  onError,\n  ...props\n}: ImagePreviewProps) {\n  const { src: contextSrc, alt, setHasError } = useImageContext(\"ImagePreview\");\n  const resolvedSrc = srcProp ?? contextSrc;\n\n  React.useEffect(() => {\n    setHasError(false);\n  }, [resolvedSrc, setHasError]);\n\n  if (!resolvedSrc) {\n    return <ImageLoader aria-hidden className={className} />;\n  }\n\n  return (\n    <DialogPrimitive.Trigger data-slot=\"image-preview-trigger\" asChild>\n      <div data-slot=\"image-preview\" className=\"relative size-full max-w-full\">\n        <ImageLoader className=\"absolute inset-0 z-0\" />\n        <img\n          {...props}\n          src={resolvedSrc}\n          alt={altProp ?? alt}\n          className={cn(\n            \"relative z-1 size-full max-w-full overflow-hidden rounded-md object-cover\",\n            className,\n          )}\n          onLoad={(e) => {\n            setHasError(false);\n            onLoad?.(e);\n          }}\n          onError={(e) => {\n            setHasError(true);\n            onError?.(e);\n          }}\n        />\n      </div>\n    </DialogPrimitive.Trigger>\n  );\n}\n\nexport type ImageLightboxProps = React.ComponentProps<\n  typeof DialogPrimitive.Portal\n>;\n\nfunction ImageLightbox(props: ImageLightboxProps) {\n  return <DialogPrimitive.Portal data-slot=\"image-lightbox\" {...props} />;\n}\n\nexport type ImageLightboxOverlayProps = React.ComponentProps<\n  typeof DialogPrimitive.Overlay\n>;\n\nfunction ImageLightboxOverlay({\n  className,\n  ...props\n}: ImageLightboxOverlayProps) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"image-lightbox-overlay\"\n      className={cn(\n        \"fixed inset-0 z-50 bg-background/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport type ImageLightboxPreviewProps = React.ComponentProps<\n  typeof DialogPrimitive.Content\n> & {\n  src?: string;\n  alt?: string;\n};\n\nfunction ImageLightboxPreview({\n  className,\n  children,\n  onInteractOutside,\n  ...props\n}: ImageLightboxPreviewProps) {\n  const { src: contextSrc, alt } = useImageContext(\"ImageLightboxPreview\");\n  const resolvedSrc = contextSrc;\n  return (\n    <DialogPrimitive.Content\n      data-slot=\"image-lightbox-preview\"\n      className={cn(\n        \"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-2xl duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg xl:max-w-2xl\",\n        className,\n      )}\n      onInteractOutside={(e) => {\n        const target = e.target as HTMLElement | null;\n        if (target?.closest('[data-slot=\"image-actions\"]')) {\n          e.preventDefault();\n        }\n        onInteractOutside?.(e);\n      }}\n      {...props}\n    >\n      <img\n        data-slot=\"image-lightbox-image\"\n        src={resolvedSrc}\n        alt={alt}\n        className={cn(\"size-full object-contain\")}\n      />\n      {children}\n      <ImageLightboxTitle>{alt || \"Image\"}</ImageLightboxTitle>\n    </DialogPrimitive.Content>\n  );\n}\n\nexport type ImageLightboxCloseProps = React.ComponentProps<\n  typeof DialogPrimitive.Close\n>;\n\nfunction ImageLightboxClose(props: ImageLightboxCloseProps) {\n  return (\n    <DialogPrimitive.Close\n      data-slot=\"image-lightbox-close\"\n      className=\"sr-only\"\n      {...props}\n    />\n  );\n}\n\nfunction ImageLightboxTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"image-lightbox-title\"\n      className={cn(\"sr-only\", className)}\n      {...props}\n    />\n  );\n}\n\nexport type ImageLoaderProps = React.HTMLAttributes<HTMLDivElement>;\n\nfunction ImageLoader({ className, ...props }: ImageLoaderProps) {\n  const { hasError } = useImageContext(\"ImageLoader\");\n\n  return (\n    <div\n      data-slot=\"image-loader\"\n      className={cn(\n        \"size-full animate-pulse rounded-lg bg-input\",\n        hasError && \"bg-destructive/20\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport type ImageActionsAlign =\n  | \"inline-start\"\n  | \"inline-end\"\n  | \"block-start\"\n  | \"block-end\";\n\nconst imageActionsAlignStyles: Record<ImageActionsAlign, string> = {\n  \"inline-start\": \"top-1/2 left-0 -translate-y-1/2 flex-col h-full\",\n  \"inline-end\": \"top-1/2 right-0 -translate-y-1/2 flex-col h-full\",\n  \"block-start\": \"top-0 left-1/2 -translate-x-1/2 w-full\",\n  \"block-end\": \"bottom-0 left-1/2 -translate-x-1/2 w-full\",\n};\n\nexport type ImageActionsProps = React.HTMLAttributes<HTMLDivElement> & {\n  align?: ImageActionsAlign;\n};\n\nfunction ImageActions({\n  align = \"block-end\",\n  className,\n  ...props\n}: ImageActionsProps) {\n  return (\n    <div\n      data-slot=\"image-actions\"\n      className={cn(\n        \"pointer-events-none absolute z-10 flex shrink-0 items-center justify-between px-3 py-3\",\n        imageActionsAlignStyles[align],\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\ntype ImageActionGroupProps = React.HTMLAttributes<HTMLDivElement>;\n\nfunction ImageActionGroup({ className, ...props }: ImageActionGroupProps) {\n  return (\n    <div\n      data-slot=\"image-action-group\"\n      className={cn(\"flex items-center gap-1.5\", className)}\n      {...props}\n    />\n  );\n}\n\nexport type ImageActionProps = React.HTMLAttributes<HTMLDivElement> & {\n  asChild?: boolean;\n  tooltip?:\n    | string\n    | {\n        content?: string;\n        side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n        shortcut?: string;\n      };\n};\n\nfunction ImageAction({\n  asChild = false,\n  tooltip,\n  className,\n  ...props\n}: ImageActionProps) {\n  const Comp = asChild ? Slot : \"div\";\n  const { content, side, shortcut } =\n    typeof tooltip === \"string\" ? { content: tooltip } : tooltip ?? {};\n\n  if (!content) {\n    return (\n      <Comp\n        data-slot=\"image-action\"\n        className={cn(\"pointer-events-auto\", className)}\n        {...props}\n      />\n    );\n  }\n\n  return (\n    <TooltipProvider delayDuration={200}>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Comp\n            data-slot=\"image-action\"\n            className={cn(\"pointer-events-auto\", className)}\n            {...props}\n          />\n        </TooltipTrigger>\n        <TooltipContent className=\"rounded-full\" side={side}>\n          {content}\n          {shortcut ? <Kbd className=\"rounded-md!\">{shortcut}</Kbd> : null}\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n\nImage.displayName = \"Image\";\nImagePreview.displayName = \"ImagePreview\";\nImageLightbox.displayName = \"ImageLightbox\";\nImageLightboxOverlay.displayName = \"ImageLightboxOverlay\";\nImageLightboxPreview.displayName = \"ImageLightboxPreview\";\nImageLightboxClose.displayName = \"ImageLightboxClose\";\nImageLoader.displayName = \"ImageLoader\";\nImageActions.displayName = \"ImageActions\";\nImageActionGroup.displayName = \"ImageActionGroup\";\nImageAction.displayName = \"ImageAction\";\n\nexport {\n  Image,\n  ImagePreview,\n  ImageLightbox,\n  ImageLightboxOverlay,\n  ImageLightboxPreview,\n  ImageLightboxClose,\n  ImageLoader,\n  ImageActions,\n  ImageActionGroup,\n  ImageAction,\n};\n","type":"registry:file","target":"~/components/nexus-ui/image.tsx"}],"type":"registry:ui"}