'use client' import { memo, useEffect, useCallback, useState, useMemo, useRef } from 'react' import { createPortal } from 'react-dom' import { cn } from '@djangocfg/ui-core/lib' import { useAppT } from '@djangocfg/i18n' import { X, ChevronLeft, ChevronRight, Download, Share2, ZoomIn, ZoomOut } from 'lucide-react' import { useSwipe } from '../../hooks/useSwipe' import { usePreloadImages } from '../../hooks/usePreloadImages' import { useZoom } from '../../hooks/useZoom' import { GalleryMedia } from '../media' import { GalleryThumbnails } from '../thumbnails' import type { GalleryLightboxProps } from '../../types' // Liquid-glass control — token-driven (--card / --foreground), so it stays // legible on BOTH the light scrim and the dark vibrancy backdrop. Foreground // icon color flips with the theme; hover wash adapts via the foreground token. const GLASS_BTN = 'glass-liquid text-foreground/80 transition-colors ' + 'hover:text-foreground hover:bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)] ' + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring' /** * GalleryLightbox - Fullscreen image viewer with zoom and navigation * * Features: * - Preloads adjacent images for smooth navigation * - Loading skeleton while image loads * - Zoom with pan/drag support * - Keyboard navigation (arrows, Home/End, Esc), focus trap, backdrop click * - Swipe navigation */ export const GalleryLightbox = memo(function GalleryLightbox({ open, onClose, images, currentIndex, onIndexChange, showThumbnails = true, enableZoom = true, enableDownload = true, enableShare = true, title, }: GalleryLightboxProps) { const t = useAppT() const [mounted, setMounted] = useState(false) const dialogRef = useRef(null) // Element focused before the lightbox opened, restored on close const previousFocusRef = useRef(null) // Translations const labels = useMemo(() => ({ lightbox: t('tools.gallery.lightbox'), previous: t('tools.gallery.previous'), next: t('tools.gallery.next'), close: t('tools.gallery.close'), download: t('tools.gallery.download'), share: t('tools.gallery.share'), zoomIn: t('tools.image.zoomIn'), zoomOut: t('tools.image.zoomOut'), }), [t]) // Unified zoom (handles both desktop click/drag and mobile pinch/tap) const zoom = useZoom({ minScale: 1, maxScale: 3, desktopScale: 2, }) // Prepare data const currentImage = useMemo(() => images[currentIndex], [images, currentIndex]) const hasMultiple = useMemo(() => images.length > 1, [images.length]) const isVideo = currentImage?.type === 'video' && Boolean(currentImage.videoSrc) const { isZoomed, isDragging } = zoom const resetZoom = zoom.reset // Preload adjacent images when lightbox is open usePreloadImages(open ? images : [], currentIndex, 2) // Handle client-side mounting for portal useEffect(() => { setMounted(true) }, []) // Reset zoom when image changes useEffect(() => { resetZoom() }, [currentIndex, resetZoom]) // Prevent body scroll when open useEffect(() => { if (!open) return const originalStyle = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { document.body.style.overflow = originalStyle } }, [open]) // Focus management: move focus into the dialog on open, restore on close useEffect(() => { if (!open || !mounted) return previousFocusRef.current = document.activeElement as HTMLElement | null // Defer to allow the portal content to mount const id = window.requestAnimationFrame(() => { dialogRef.current?.focus() }) return () => { window.cancelAnimationFrame(id) previousFocusRef.current?.focus?.() } }, [open, mounted]) // Navigation handlers const goToPrev = useCallback(() => { if (hasMultiple) { onIndexChange((currentIndex - 1 + images.length) % images.length) } }, [hasMultiple, currentIndex, images.length, onIndexChange]) const goToNext = useCallback(() => { if (hasMultiple) { onIndexChange((currentIndex + 1) % images.length) } }, [hasMultiple, currentIndex, images.length, onIndexChange]) // Keyboard navigation + focus trap useEffect(() => { if (!open) return const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'Escape': e.preventDefault() if (isZoomed) { resetZoom() } else { onClose() } break case 'ArrowLeft': if (!isZoomed) goToPrev() break case 'ArrowRight': if (!isZoomed) goToNext() break case 'Home': if (!isZoomed && hasMultiple) { e.preventDefault() onIndexChange(0) } break case 'End': if (!isZoomed && hasMultiple) { e.preventDefault() onIndexChange(images.length - 1) } break case 'Tab': { // Trap focus within the dialog const dialog = dialogRef.current if (!dialog) break const focusable = dialog.querySelectorAll( 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])' ) if (focusable.length === 0) { e.preventDefault() break } const first = focusable[0] const last = focusable[focusable.length - 1] const active = document.activeElement if (e.shiftKey && (active === first || active === dialog)) { e.preventDefault() last.focus() } else if (!e.shiftKey && active === last) { e.preventDefault() first.focus() } break } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [open, isZoomed, goToPrev, goToNext, onClose, resetZoom, hasMultiple, images.length, onIndexChange]) // Swipe handlers (disabled when zoomed) const swipeHandlers = useSwipe({ onSwipeLeft: isZoomed ? undefined : goToNext, onSwipeRight: isZoomed ? undefined : goToPrev, }) // Action handlers const handleDownload = useCallback(() => { if (!currentImage) return // For videos download the video file, otherwise the image const downloadUrl = isVideo ? currentImage.videoSrc! : currentImage.src // Derive a filename from the URL path, fall back to a numbered name let filename = isVideo ? `video-${currentIndex + 1}.mp4` : `image-${currentIndex + 1}.jpg` try { const path = new URL(downloadUrl, window.location.href).pathname const last = path.split('/').pop() if (last && /\.[a-z0-9]+$/i.test(last)) { filename = decodeURIComponent(last) } } catch { // Keep fallback filename } const link = document.createElement('a') link.href = downloadUrl link.download = filename link.rel = 'noopener' document.body.appendChild(link) link.click() document.body.removeChild(link) }, [currentImage, currentIndex, isVideo]) const handleShare = useCallback(async () => { if (!currentImage) return if (navigator.share) { try { await navigator.share({ title: title || 'Image', url: currentImage.src, }) } catch { // User cancelled or error } } else { await navigator.clipboard.writeText(currentImage.src) } }, [currentImage, title]) const handleToggleZoom = useCallback(() => { zoom.toggleZoom() }, [zoom]) // Close only when the backdrop itself (not a child) is clicked const handleBackdropClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) onClose() }, [onClose]) if (!open || !mounted) return null const lightbox = (
{/* Content */}
{/* Header */}
{/* Title & Counter — liquid-glass pill so it reads on both the light and dark scrim (glass-liquid is token-driven, foreground text flips with the theme). */} {(title || hasMultiple) ? (
{title &&
{title}
} {hasMultiple && (
{currentIndex + 1} / {images.length}
)}
) : ( )} {/* Actions */}
{enableZoom && !isVideo && ( )} {enableDownload && ( )} {enableShare && ( )}
{/* Main Image */}
{currentImage && (
)}
{/* Navigation Arrows */} {hasMultiple && !isZoomed && ( <> )} {/* Thumbnails */} {showThumbnails && hasMultiple && !isZoomed && (
)}
) return createPortal(lightbox, document.body) })