'use client' import * as React from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@djangocfg/ui-core/lib' import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, type CarouselApi, } from '@djangocfg/ui-core/components' import { ZoomIn } from 'lucide-react' import { GalleryMedia } from '../media' import { GalleryThumbnails } from '../thumbnails' import type { GalleryMediaItem } from '../../types' export interface GalleryCarouselProps { images: GalleryMediaItem[] currentIndex: number total: number hasMultiple: boolean initialIndex?: number aspectRatio?: number showControls?: boolean showCounter?: boolean showThumbnails?: boolean enableLightbox?: boolean enableKeyboard?: boolean isLightboxOpen?: boolean onApiChange?: (api: CarouselApi | undefined) => void onIndexChange: (index: number) => void onLightboxOpen?: () => void onImageLoad?: () => void className?: string } /** * GalleryCarousel - Embla-based carousel for gallery images * * Features: * - Smooth slide transitions * - Touch/swipe support * - Keyboard navigation * - Mobile dots + desktop thumbnails */ export const GalleryCarousel = memo(function GalleryCarousel({ images, currentIndex, total, hasMultiple, initialIndex = 0, aspectRatio = 16 / 9, showControls = true, showCounter = true, showThumbnails = true, enableLightbox = true, enableKeyboard = true, isLightboxOpen = false, onApiChange, onIndexChange, onLightboxOpen, onImageLoad, className, }: GalleryCarouselProps) { const [api, setApi] = React.useState() // Track if component is mounted (client-side) to avoid hydration mismatches const [isMounted, setIsMounted] = useState(false) // Stable key for carousel reset on images change const carouselKey = images[0]?.id ?? 'empty' useEffect(() => { setIsMounted(true) }, []) // Notify parent when API is ready useEffect(() => { onApiChange?.(api) }, [api, onApiChange]) // Mobile dots (max 5) const mobileDots = useMemo(() => { return images.slice(0, 5).map((_, index) => index) }, [images]) const remainingCount = useMemo(() => { return images.length > 5 ? images.length - 5 : 0 }, [images.length]) // Sync carousel with external state useEffect(() => { if (!api) return api.scrollTo(currentIndex) }, [api, currentIndex]) // Listen to carousel changes useEffect(() => { if (!api) return const onSelect = () => { const index = api.selectedScrollSnap() if (index !== currentIndex) { onIndexChange(index) } } api.on('select', onSelect) return () => { api.off('select', onSelect) } }, [api, currentIndex, onIndexChange]) // Keyboard navigation useEffect(() => { if (!enableKeyboard || isLightboxOpen || !api) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft' && hasMultiple) { api.scrollPrev() } else if (e.key === 'ArrowRight' && hasMultiple) { api.scrollNext() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [enableKeyboard, isLightboxOpen, hasMultiple, api]) // Dot click handler const handleDotClick = useCallback( (e: React.MouseEvent, index: number) => { e.stopPropagation() api?.scrollTo(index) }, [api] ) // Scroll to index const handleScrollTo = useCallback( (index: number) => { api?.scrollTo(index) }, [api] ) // Track pointer down position to distinguish click from drag const pointerStartRef = useRef<{ x: number; y: number; time: number } | null>(null) const CLICK_THRESHOLD = 10 // pixels const CLICK_TIME_THRESHOLD = 300 // ms const handlePointerDown = useCallback((e: React.PointerEvent) => { pointerStartRef.current = { x: e.clientX, y: e.clientY, time: Date.now() } }, []) const handlePointerUp = useCallback((e: React.PointerEvent) => { if (!pointerStartRef.current || !enableLightbox || !onLightboxOpen) return const { x, y, time } = pointerStartRef.current const dx = Math.abs(e.clientX - x) const dy = Math.abs(e.clientY - y) const dt = Date.now() - time // Don't open lightbox when interacting with video controls const target = e.target as HTMLElement const isVideoInteraction = target.tagName === 'VIDEO' || target.closest('video, [data-nav]') !== null // Only trigger click if pointer didn't move much and was quick if ( !isVideoInteraction && dx < CLICK_THRESHOLD && dy < CLICK_THRESHOLD && dt < CLICK_TIME_THRESHOLD ) { onLightboxOpen() } pointerStartRef.current = null }, [enableLightbox, onLightboxOpen]) // SSR fallback - render first image only to avoid hydration mismatch // The carousel requires DOM access and will cause issues during SSR if (!isMounted) { return (
{/* Dark overlay */}
{/* Counter */} {showCounter && hasMultiple && (
1 / {total}
)} {/* Mobile dots */} {hasMultiple && (
{mobileDots.map((index) => (
))} {remainingCount > 0 && ( +{remainingCount} )}
)}
{/* Thumbnails placeholder */} {showThumbnails && hasMultiple && (
{images.slice(0, 8).map((image, index) => (
))}
)}
) } return (
{images.map((image, index) => (
{/* Dark overlay */}
{/* Lightbox indicator */} {enableLightbox && (
)}
))} {/* Navigation Controls */} {showControls && hasMultiple && ( <> )} {/* Counter */} {showCounter && hasMultiple && (
{currentIndex + 1} / {total}
)} {/* Mobile dots */} {hasMultiple && (
{mobileDots.map((index) => (
)} {/* Thumbnails */} {showThumbnails && hasMultiple && ( )}
) })