'use client' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { cn } from '@djangocfg/ui-core/lib' import { Play } from 'lucide-react' import type { GalleryMediaItem } from '../../types' import { normalizeImageUrl } from '../../utils' import { ImageSpinner } from '../shared' export type GalleryGridLayout = | 'auto' | 'single' | 'two-cols' | 'hero-left' | 'grid-2x2' | 'mosaic-5' export interface GalleryGridProps { /** Array of images to display */ images: GalleryMediaItem[] /** Maximum images to show in grid */ maxVisible?: number /** Grid layout (auto picks based on count) */ layout?: GalleryGridLayout /** Aspect ratio for the grid container */ aspectRatio?: number /** Gap between grid items (tailwind spacing) */ gap?: 1 | 2 | 3 | 4 /** Border radius */ rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' /** Callback when image is clicked */ onImageClick?: (index: number) => void /** Show "+N more" badge */ showMoreBadge?: boolean /** Show loading skeleton */ loading?: boolean /** Stagger delay between image reveals (ms), 0 to disable */ staggerDelay?: number /** Additional CSS class */ className?: string } const GAP_CLASSES = { 1: 'gap-1', 2: 'gap-2', 3: 'gap-3', 4: 'gap-4', } as const const ROUNDED_CLASSES = { none: 'rounded-none', sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', '2xl': 'rounded-2xl', } as const /** * Get layout based on image count */ function getLayoutForCount(count: number): GalleryGridLayout { if (count === 1) return 'single' if (count === 2) return 'two-cols' if (count === 3) return 'hero-left' if (count === 4) return 'grid-2x2' return 'mosaic-5' } /** * GalleryGrid - Fixed layout grid for property images * * Airbnb-style layouts based on image count: * - 1 image: full width * - 2 images: side by side * - 3 images: hero left + 2 stacked right * - 4 images: 2x2 grid * - 5+ images: hero left + 2x2 grid right */ export const GalleryGrid = memo(function GalleryGrid({ images, maxVisible = 5, layout = 'auto', aspectRatio = 16 / 9, gap = 2, rounded = 'xl', onImageClick, showMoreBadge = true, loading = false, staggerDelay = 75, className, }: GalleryGridProps) { const visibleImages = useMemo(() => { return images.slice(0, maxVisible) }, [images, maxVisible]) const currentLayout = useMemo((): GalleryGridLayout => { if (layout !== 'auto') return layout return getLayoutForCount(visibleImages.length || maxVisible) }, [layout, visibleImages.length, maxVisible]) const remainingCount = useMemo(() => { return Math.max(0, images.length - maxVisible) }, [images.length, maxVisible]) const handleClick = useCallback( (index: number) => { onImageClick?.(index) }, [onImageClick] ) // Show skeleton when loading or no images yet if (loading || images.length === 0) { return (
) } return (
) }) // Grid layout renderer interface GridLayoutProps { layout: GalleryGridLayout images: GalleryMediaItem[] gap: 1 | 2 | 3 | 4 remainingCount: number showMoreBadge: boolean staggerDelay: number onImageClick: (index: number) => void } const GridLayout = memo(function GridLayout({ layout, images, gap, remainingCount, showMoreBadge, staggerDelay, onImageClick, }: GridLayoutProps) { if (!images || images.length === 0) return null switch (layout) { case 'single': return ( ) case 'two-cols': return (
{images.slice(0, 2).map((image, index) => ( 0} badgeCount={remainingCount} /> ))}
) case 'hero-left': return (
{images.slice(1, 3).map((image, index) => ( 0} badgeCount={remainingCount} /> ))}
) case 'grid-2x2': return (
{images.slice(0, 4).map((image, index) => ( 0} badgeCount={remainingCount} /> ))}
) case 'mosaic-5': default: // Airbnb-style: hero (50%) + 2x2 grid (50%) using single grid with areas return (
{images[1] && ( )} {images[2] && ( )} {images[3] && ( )} {images[4] && ( 0} badgeCount={remainingCount} style={{ gridArea: 'img5' }} /> )}
) } }) // Single grid item interface GridItemProps { image: GalleryMediaItem index: number staggerDelay: number onClick: (index: number) => void className?: string style?: React.CSSProperties showBadge?: boolean badgeCount?: number } const GridItem = memo(function GridItem({ image, index, staggerDelay, onClick, className, style, showBadge = false, badgeCount = 0, }: GridItemProps) { const [isLoaded, setIsLoaded] = useState(false) const [hasError, setHasError] = useState(false) // Reset loading state when image source changes const imageSrc = image?.thumbnail || image?.src useEffect(() => { setIsLoaded(false) setHasError(false) }, [imageSrc]) const handleClick = useCallback(() => { onClick(index) }, [onClick, index]) const handleLoad = useCallback(() => { setIsLoaded(true) }, []) const handleError = useCallback(() => { setHasError(true) }, []) if (!image) return null const isVideo = image.type === 'video' const animationDelay = staggerDelay > 0 ? `${index * staggerDelay}ms` : '0ms' return ( ) }) // Skeleton component matching grid layouts interface GridSkeletonProps { layout: GalleryGridLayout gap: 1 | 2 | 3 | 4 } const GridSkeleton = memo(function GridSkeleton({ layout, gap }: GridSkeletonProps) { const skeletonCell = "bg-muted animate-pulse" switch (layout) { case 'single': return
case 'two-cols': return (
) case 'hero-left': return (
) case 'grid-2x2': return (
) case 'mosaic-5': default: return (
) } })