'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 (
)
}
})