{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-gallery",
  "title": "Image Gallery",
  "description": "Product image carousel with a synced thumbnail strip.",
  "dependencies": [
    "@channel3/sdk",
    "lucide-react"
  ],
  "registryDependencies": [
    "carousel"
  ],
  "files": [
    {
      "path": "registry/default/components/image-gallery.tsx",
      "content": "import * as React from \"react\";\nimport { ImageOff } from \"lucide-react\";\nimport type { ProductImage } from \"@channel3/sdk/resources\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  type CarouselApi,\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselNext,\n  CarouselPrevious,\n} from \"@/components/ui/carousel\";\n\nfunction GalleryImage({\n  image,\n  priority = false,\n}: {\n  image: ProductImage;\n  priority?: boolean;\n}) {\n  const [failed, setFailed] = React.useState(false);\n  const [loaded, setLoaded] = React.useState(false);\n  // A server-rendered image can finish decoding before hydration, so `onLoad`\n  // never fires on the client — reveal it on mount if it's already complete.\n  const revealIfComplete = React.useCallback((node: HTMLImageElement | null) => {\n    if (node?.complete) {\n      setLoaded(true);\n    }\n  }, []);\n  if (failed) {\n    return (\n      <div className=\"absolute inset-0 flex items-center justify-center text-muted-foreground\">\n        <ImageOff className=\"size-8\" aria-hidden />\n      </div>\n    );\n  }\n  return (\n    <img\n      src={image.url}\n      alt={image.alt_text ?? \"\"}\n      loading={priority ? \"eager\" : \"lazy\"}\n      fetchPriority={priority ? \"high\" : undefined}\n      decoding=\"async\"\n      className={cn(\n        \"absolute inset-0 size-full object-cover transition-opacity duration-300\",\n        loaded ? \"opacity-100\" : \"opacity-0\",\n      )}\n      ref={revealIfComplete}\n      onLoad={() => setLoaded(true)}\n      onError={() => setFailed(true)}\n    />\n  );\n}\n\nexport interface ImageGalleryProps extends React.ComponentProps<\"div\"> {\n  images: ReadonlyArray<ProductImage>;\n  /**\n   * Transient image to overlay on the active slide (e.g. a hovered variant\n   * swatch's `thumbnail_url`). The carousel state is untouched; clearing this\n   * (`null`/`undefined`) reveals the underlying slide again.\n   */\n  previewSrc?: string | null;\n}\n\nexport function ImageGallery({ images, previewSrc, className, ...props }: ImageGalleryProps) {\n  const [api, setApi] = React.useState<CarouselApi>();\n  const [selected, setSelected] = React.useState(0);\n\n  React.useEffect(() => {\n    if (!api) {\n      return;\n    }\n    const sync = () => setSelected(api.selectedScrollSnap());\n    sync();\n    api.on(\"select\", sync);\n    api.on(\"reInit\", sync);\n    return () => {\n      api.off(\"select\", sync);\n      api.off(\"reInit\", sync);\n    };\n  }, [api]);\n\n  if (images.length === 0) {\n    return (\n      <div\n        data-slot=\"image-gallery\"\n        className={cn(\n          \"flex aspect-square items-center justify-center rounded-lg bg-muted text-muted-foreground\",\n          className,\n        )}\n        {...props}\n      >\n        <ImageOff className=\"size-10\" aria-hidden />\n      </div>\n    );\n  }\n\n  const multiple = images.length > 1;\n\n  return (\n    <div data-slot=\"image-gallery\" className={cn(\"flex flex-col gap-2\", className)} {...props}>\n      <div className=\"relative\">\n        <Carousel setApi={setApi} className=\"w-full\">\n          <CarouselContent>\n            {images.map((image, index) => (\n              <CarouselItem key={`${image.url}-${index}`}>\n                <div className=\"relative overflow-hidden rounded-lg bg-muted\">\n                  {/* Invisible sizer: a square, plus the thumbnail strip's own\n                      height when there's no strip — so a single image fills the\n                      same footprint as a multi-image gallery. */}\n                  <div aria-hidden className=\"invisible flex flex-col gap-2\">\n                    <div className=\"aspect-square\" />\n                    {multiple ? null : <div className=\"size-14\" />}\n                  </div>\n                  <GalleryImage image={image} priority={index === 0} />\n                </div>\n              </CarouselItem>\n            ))}\n          </CarouselContent>\n          {multiple ? (\n            <>\n              <CarouselPrevious className=\"left-2\" />\n              <CarouselNext className=\"right-2\" />\n            </>\n          ) : null}\n        </Carousel>\n        {previewSrc ? (\n          <img\n            src={previewSrc}\n            alt=\"\"\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 size-full rounded-lg bg-muted object-cover\"\n          />\n        ) : null}\n      </div>\n\n      {multiple ? (\n        <div className=\"flex items-start gap-2 overflow-x-auto scrollbar-none [&::-webkit-scrollbar]:hidden\">\n          {images.map((image, index) => (\n            <button\n              key={`thumb-${image.url}-${index}`}\n              type=\"button\"\n              onClick={() => api?.scrollTo(index)}\n              onMouseEnter={() => api?.scrollTo(index, true)}\n              onFocus={() => api?.scrollTo(index, true)}\n              aria-label={`View image ${index + 1}`}\n              aria-current={index === selected}\n              className={cn(\n                \"relative size-14 shrink-0 overflow-hidden rounded-md border bg-muted transition-opacity\",\n                index === selected\n                  ? \"border-ring ring-1 ring-ring\"\n                  : \"opacity-60 hover:opacity-100\",\n              )}\n            >\n              <GalleryImage image={image} />\n            </button>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}