Todos os blocos

Gallery Carousel

Image gallery carousel for listing detail pages. Takes the media[] array from ListingDetail and renders a navigable slideshow with thumbnails.

Instalar

npx shadcn@latest add https://sdk.garagem.site/r/gallery-carousel.json
Abrir no v0.dev

Dependências npm

@garagem-ai/site-sdklucide-react

Componentes shadcn (auto-instalados)

button

Arquivos instalados

  • components/garagem/gallery-carousel.tsx

Código fonte

gallery-carousel/gallery-carousel.tsx
"use client";

import { useState, useCallback } from "react";
import Image from "next/image";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { ListingMedia } from "@garagem-ai/site-sdk";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export interface GalleryCarouselProps {
  /** Array of media items from `ListingDetail.media`. */
  media: ListingMedia[];
  /** Tailwind aspect-ratio class. Defaults to `aspect-[16/9]`. */
  aspectRatio?: string;
  /** Show thumbnail strip below the main image. Defaults to true. */
  showThumbnails?: boolean;
  /** Maximum number of thumbnails to show. Defaults to 8. */
  maxThumbnails?: number;
  /** Pass-through className for the outer wrapper. */
  className?: string;
}

/**
 * Client-side image gallery carousel for listing detail pages.
 * Takes the `media[]` array from `ListingDetail` (via `getListing`).
 *
 * Keyboard-accessible: left/right arrows navigate slides.
 */
export function GalleryCarousel({
  media,
  aspectRatio = "aspect-[16/9]",
  showThumbnails = true,
  maxThumbnails = 8,
  className,
}: GalleryCarouselProps) {
  const images = media.filter((m) => m.type === "image" || !m.type);
  const [current, setCurrent] = useState(0);

  const goTo = useCallback(
    (index: number) => {
      setCurrent((index + images.length) % images.length);
    },
    [images.length]
  );

  const prev = useCallback(() => goTo(current - 1), [current, goTo]);
  const next = useCallback(() => goTo(current + 1), [current, goTo]);

  if (images.length === 0) {
    return (
      <div
        className={cn(
          "flex items-center justify-center rounded-lg bg-muted",
          aspectRatio,
          className
        )}
      >
        <p className="text-sm text-muted-foreground">Sem fotos</p>
      </div>
    );
  }

  const currentImage = images[current]!;

  return (
    <div
      className={cn("flex flex-col gap-2", className)}
      onKeyDown={(e) => {
        if (e.key === "ArrowLeft") prev();
        if (e.key === "ArrowRight") next();
      }}
      tabIndex={0}
      role="region"
      aria-label="Galeria de imagens"
      aria-roledescription="carousel"
    >
      <div
        className={cn(
          "relative w-full overflow-hidden rounded-lg",
          aspectRatio
        )}
      >
        <Image
          src={currentImage.src}
          alt={
            currentImage.alt ?? currentImage.caption ?? `Foto ${current + 1}`
          }
          fill
          sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
          className="object-cover"
          priority={current === 0}
        />

        {images.length > 1 && (
          <>
            <Button
              variant="ghost"
              size="icon"
              className="absolute left-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur-sm hover:bg-background/90"
              onClick={prev}
              aria-label="Foto anterior"
            >
              <ChevronLeft className="size-5" />
            </Button>
            <Button
              variant="ghost"
              size="icon"
              className="absolute right-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur-sm hover:bg-background/90"
              onClick={next}
              aria-label="Próxima foto"
            >
              <ChevronRight className="size-5" />
            </Button>

            <div className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-background/80 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
              {current + 1} / {images.length}
            </div>
          </>
        )}
      </div>

      {showThumbnails && images.length > 1 && (
        <div className="flex gap-1.5 overflow-x-auto pb-1">
          {images.slice(0, maxThumbnails).map((img, i) => (
            <button
              key={img.id}
              type="button"
              onClick={() => goTo(i)}
              className={cn(
                "relative h-14 w-20 flex-shrink-0 overflow-hidden rounded-md transition-opacity",
                i === current
                  ? "ring-2 ring-primary opacity-100"
                  : "opacity-60 hover:opacity-100"
              )}
              aria-label={`Ver foto ${i + 1}`}
              aria-current={i === current ? "true" : undefined}
            >
              <Image
                src={img.src}
                alt={img.alt ?? `Miniatura ${i + 1}`}
                fill
                sizes="80px"
                className="object-cover"
              />
            </button>
          ))}
          {images.length > maxThumbnails && (
            <div className="flex h-14 w-20 flex-shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
              +{images.length - maxThumbnails}
            </div>
          )}
        </div>
      )}
    </div>
  );
}