'use client' import { memo, useCallback, useEffect, useState, useMemo } from 'react' import { cn } from '@djangocfg/ui-core/lib' import { Carousel, CarouselContent, CarouselItem, type CarouselApi, } from '@djangocfg/ui-core/components' import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react' import { GalleryMedia } from '../media' import type { GalleryMediaItem } from '../../types' /** Preset aspect ratios for common use cases */ export type GalleryAspectRatio = '16/9' | '4/3' | '3/2' | '1/1' | 'auto' export interface GalleryCompactProps { /** Array of images to display */ images: GalleryMediaItem[] /** Aspect ratio preset or 'auto' to fill parent (default: 'auto') */ aspectRatio?: GalleryAspectRatio /** Show dots indicator (default: true) */ showDots?: boolean /** Max dots to show (default: 5) */ maxDots?: number /** Show counter badge (default: false) */ showCounter?: boolean /** Show navigation arrows on hover (default: true) */ showArrows?: boolean /** Enable zoom effect on hover (default: true) */ enableZoom?: boolean /** On image click callback */ onClick?: () => void /** Additional CSS class */ className?: string } /** * GalleryCompact - Minimal carousel for property/vehicle cards * * Features: * - Simple swipe carousel * - Dots indicator (mobile-style) * - Navigation arrows on hover (desktop) * - Hover zoom effect on images * - Lazy loading - only loads active image + neighbors * - Fills parent container * - Stops event propagation on navigation */ /** Map aspect ratio presets to CSS values */ const aspectRatioMap: Record = { '16/9': '16 / 9', '4/3': '4 / 3', '3/2': '3 / 2', '1/1': '1 / 1', 'auto': undefined, } export const GalleryCompact = memo(function GalleryCompact({ images, aspectRatio = 'auto', showDots = true, maxDots = 5, showCounter = false, showArrows = true, enableZoom = true, onClick, className, }: GalleryCompactProps) { const [api, setApi] = useState() const [currentIndex, setCurrentIndex] = useState(0) const [isHovered, setIsHovered] = useState(false) // Track if component is mounted (client-side) to avoid hydration mismatches const [isMounted, setIsMounted] = useState(false) // Compute aspect ratio style - if set, we control height via aspect-ratio, otherwise fill parent const aspectRatioStyle = aspectRatioMap[aspectRatio] const containerStyle = aspectRatioStyle ? { aspectRatio: aspectRatioStyle } : undefined const hasFixedAspect = aspectRatio !== 'auto' const containerClass = hasFixedAspect ? 'relative w-full overflow-hidden' : 'relative w-full h-full overflow-hidden' useEffect(() => { setIsMounted(true) }, []) const total = images.length const hasMultiple = total > 1 // Stable key for carousel reset on images change const carouselKey = images[0]?.id ?? 'empty' // Determine which images should be loaded (current + neighbors for smooth transition) const loadedIndices = useMemo(() => { if (total === 0) return new Set() const indices = new Set() // Always load first image indices.add(0) // Load current indices.add(currentIndex) // Load neighbors for smooth transitions if (hasMultiple) { indices.add((currentIndex - 1 + total) % total) indices.add((currentIndex + 1) % total) } return indices }, [currentIndex, total, hasMultiple]) // Listen to carousel changes useEffect(() => { if (!api) return const onSelect = () => { setCurrentIndex(api.selectedScrollSnap()) } api.on('select', onSelect) return () => { api.off('select', onSelect) } }, [api]) // Navigation handlers - stop all propagation to parent Link const stopEvent = useCallback((e: React.MouseEvent | React.PointerEvent) => { e.preventDefault() e.stopPropagation() e.nativeEvent.stopImmediatePropagation() }, []) const handlePrev = useCallback( (e: React.MouseEvent) => { stopEvent(e) api?.scrollPrev() }, [api, stopEvent] ) const handleNext = useCallback( (e: React.MouseEvent) => { stopEvent(e) api?.scrollNext() }, [api, stopEvent] ) // Dot click handler const handleDotClick = useCallback( (e: React.MouseEvent, index: number) => { e.preventDefault() e.stopPropagation() api?.scrollTo(index) }, [api] ) // Handle container click const handleClick = useCallback( (e: React.MouseEvent) => { // Don't trigger if clicking on navigation elements if ((e.target as HTMLElement).closest('[data-nav]')) { return } onClick?.() }, [onClick] ) // Empty state if (total === 0) { return (
) } // Single image - no carousel needed if (!hasMultiple) { return (
) } // Calculate visible dots const visibleDots = images.slice(0, maxDots).map((_, i) => i) const remainingCount = total > maxDots ? total - maxDots : 0 // SSR fallback - render first image only to avoid hydration mismatch // The carousel requires DOM access and will cause issues during SSR if (!isMounted) { return (
{/* Show dots indicator placeholder for consistent layout */} {showDots && (
{visibleDots.map((index) => (
))} {remainingCount > 0 && ( +{remainingCount} )}
)} {/* Counter badge placeholder */} {showCounter && (
1 / {total}
)}
) } return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {images.map((image, index) => { const shouldLoad = loadedIndices.has(index) const isActive = index === currentIndex return (
{shouldLoad ? ( ) : ( // Placeholder for unloaded images
)}
) })} {/* Navigation arrows - always visible but subtle, more prominent on hover */} {showArrows && ( <> )} {/* Dots indicator */} {showDots && (
e.stopPropagation()} > {visibleDots.map((index) => (
)} {/* Counter badge */} {showCounter && (
{currentIndex + 1} / {total}
)}
) })