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.jsonDependê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>
);
}