'use client'; import Image from 'next/image'; import * as React from 'react'; import type { PropertyMedia } from '@/lib/listings-api'; import { cn } from '@/lib/utils'; // ─── Types ────────────────────────────────────────────── interface ImageLightboxProps { images: PropertyMedia[]; initialIndex?: number; open: boolean; onClose: () => void; } // ─── Swipe hook ───────────────────────────────────────── function useSwipe(onSwipeLeft: () => void, onSwipeRight: () => void) { const touchStart = React.useRef<{ x: number; y: number } | null>(null); const touchEnd = React.useRef<{ x: number; y: number } | null>(null); const minSwipeDistance = 50; const onTouchStart = React.useCallback((e: React.TouchEvent) => { const touch = e.targetTouches[0]; if (!touch) return; touchEnd.current = null; touchStart.current = { x: touch.clientX, y: touch.clientY }; }, []); const onTouchMove = React.useCallback((e: React.TouchEvent) => { const touch = e.targetTouches[0]; if (!touch) return; touchEnd.current = { x: touch.clientX, y: touch.clientY }; }, []); const onTouchEnd = React.useCallback(() => { if (!touchStart.current || !touchEnd.current) return; const distanceX = touchStart.current.x - touchEnd.current.x; const distanceY = Math.abs(touchStart.current.y - touchEnd.current.y); // Only consider horizontal swipes (ignore vertical scrolling) if (Math.abs(distanceX) > minSwipeDistance && Math.abs(distanceX) > distanceY) { if (distanceX > 0) { onSwipeLeft(); } else { onSwipeRight(); } } }, [onSwipeLeft, onSwipeRight]); return { onTouchStart, onTouchMove, onTouchEnd }; } // ─── Focus trap hook ──────────────────────────────────── function useFocusTrap(containerRef: React.RefObject, active: boolean) { React.useEffect(() => { if (!active) return; const container = containerRef.current; if (!container) return; const previouslyFocused = document.activeElement as HTMLElement | null; const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; // Focus the first focusable element const firstFocusable = container.querySelector(focusableSelector); firstFocusable?.focus(); function handleKeyDown(e: KeyboardEvent) { if (e.key !== 'Tab' || !container) return; const focusableElements = container.querySelectorAll(focusableSelector); if (focusableElements.length === 0) return; const first = focusableElements[0] as HTMLElement | undefined; const last = focusableElements[focusableElements.length - 1] as HTMLElement | undefined; if (!first || !last) return; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } } document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); previouslyFocused?.focus(); }; }, [containerRef, active]); } // ─── Component ────────────────────────────────────────── export function ImageLightbox({ images, initialIndex = 0, open, onClose }: ImageLightboxProps) { const [currentIndex, setCurrentIndex] = React.useState(initialIndex); const [isImageLoaded, setIsImageLoaded] = React.useState(false); const containerRef = React.useRef(null); const thumbnailsRef = React.useRef(null); // Reset index when lightbox opens with a new initialIndex React.useEffect(() => { if (open) { setCurrentIndex(initialIndex); setIsImageLoaded(false); } }, [open, initialIndex]); // Lock body scroll React.useEffect(() => { if (open) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [open]); // Focus trap useFocusTrap(containerRef, open); // Navigation handlers const goToPrevious = React.useCallback(() => { setCurrentIndex((i) => (i > 0 ? i - 1 : images.length - 1)); setIsImageLoaded(false); }, [images.length]); const goToNext = React.useCallback(() => { setCurrentIndex((i) => (i < images.length - 1 ? i + 1 : 0)); setIsImageLoaded(false); }, [images.length]); // Keyboard navigation React.useEffect(() => { if (!open) return; function handleKeyDown(e: KeyboardEvent) { switch (e.key) { case 'Escape': onClose(); break; case 'ArrowLeft': goToPrevious(); break; case 'ArrowRight': goToNext(); break; } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [open, onClose, goToPrevious, goToNext]); // Swipe support const swipeHandlers = useSwipe(goToNext, goToPrevious); // Scroll active thumbnail into view React.useEffect(() => { if (!open || !thumbnailsRef.current) return; const activeThumb = thumbnailsRef.current.children[currentIndex] as HTMLElement | undefined; activeThumb?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); }, [currentIndex, open]); // Preload adjacent images React.useEffect(() => { if (!open || images.length <= 1) return; const preloadIndices = [ (currentIndex + 1) % images.length, (currentIndex - 1 + images.length) % images.length, ]; preloadIndices.forEach((idx) => { const imageToPreload = images[idx]; if (!imageToPreload) return; const img = new window.Image(); img.src = imageToPreload.url; }); }, [currentIndex, open, images]); if (!open || images.length === 0) return null; const currentImage = images[currentIndex]; if (!currentImage) return null; return (
{/* Top bar */}

{currentIndex + 1} / {images.length}

{currentImage.caption && (

{currentImage.caption}

)}
{/* Main image area */}
{/* Loading spinner */} {!isImageLoaded && (
)} {/* Image */}
{currentImage.caption setIsImageLoaded(true)} />
{/* Navigation arrows */} {images.length > 1 && ( <> )}
{/* Thumbnail strip */} {images.length > 1 && (
{images.map((img, index) => ( ))}
)}
); }