import { useEffect, useState, useRef, useCallback } from 'react' import { useGesture } from '@use-gesture/react' import { X, ChevronLeft, ChevronRight, Download, Copy, ZoomIn, ZoomOut, Maximize, Minimize, Play, ChevronDown, HardDrive, Calendar, CalendarClock, Image as ImageIcon, Eye, Pencil, Film, Clock, RotateCcw, } from 'lucide-react' import { formatBytes, formatDate, formatPlatformName } from '../lib/utils' import { notificationManager } from '../lib/notificationManager' // Helper to format video duration const formatDuration = (seconds: number): string => { if (!seconds || seconds <= 0) return '' const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) const s = Math.floor(seconds % 60) if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` return `${m}:${s.toString().padStart(2, '0')}` } // Helper to format resolution - uses shorter dimension for accurate labeling // A 720x1280 portrait video is 720p, a 1920x1080 landscape video is 1080p const formatResolution = (width: number, height: number): string => { if (!width || !height) return '' // Use the shorter dimension for resolution label (handles portrait videos correctly) const shortSide = Math.min(width, height) if (shortSide >= 2160) return '4K' if (shortSide >= 1440) return '1440p' if (shortSide >= 1080) return '1080p' if (shortSide >= 720) return '720p' if (shortSide >= 480) return '480p' if (shortSide >= 360) return '360p' return `${shortSide}p` } interface EnhancedLightboxProps { items: any[] currentIndex: number onClose: () => void onNavigate: (index: number) => void onDelete?: (item: any) => void onEditDate?: (item: any) => void getPreviewUrl: (item: any) => string getThumbnailUrl: (item: any) => string isVideo: (item: any) => boolean renderActions?: (item: any) => React.ReactNode hideFaceRecognition?: boolean } export default function EnhancedLightbox({ items, currentIndex, onClose, onNavigate, onDelete, onEditDate, getPreviewUrl, getThumbnailUrl, isVideo, renderActions, hideFaceRecognition = false, }: EnhancedLightboxProps) { const currentItem = items[currentIndex] const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const [isPanning, setIsPanning] = useState(false) const [panStart, setPanStart] = useState({ x: 0, y: 0 }) const [isFullscreen, setIsFullscreen] = useState(false) const [showMetadata, setShowMetadata] = useState(false) // Embedded file metadata (from ffprobe/exiftool) const [embeddedMetadata, setEmbeddedMetadata] = useState<{ title?: string | null artist?: string | null description?: string | null comment?: string | null date?: string | null source?: string | null } | null>(null) const [embeddedMetadataLoading, setEmbeddedMetadataLoading] = useState(false) // Loading states const [imageLoading, setImageLoading] = useState(true) const [imageError, setImageError] = useState(false) const [preloadedImages, setPreloadedImages] = useState>(new Set()) // Image dimensions for accurate zoom percentage calculation const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null) const [displayedSize, setDisplayedSize] = useState<{ width: number; height: number } | null>(null) // Calculate max zoom to allow reaching 100% of original image (plus 50% extra for zooming beyond) // If image is displayed at 25% of original, maxZoom = 4 to reach 100%, then *1.5 = 6 for extra zoom const maxZoom = naturalSize && displayedSize ? Math.max(5, Math.ceil((naturalSize.width / displayedSize.width) * 1.5)) : 5 const containerRef = useRef(null) const imageRef = useRef(null) const videoRef = useRef(null) const thumbnailStripRef = useRef(null) // Preload adjacent images useEffect(() => { const preloadImage = (index: number) => { if (index < 0 || index >= items.length || preloadedImages.has(index)) return if (isVideo(items[index])) return const img = new Image() img.src = getPreviewUrl(items[index]) img.onload = () => { setPreloadedImages(prev => new Set([...prev, index])) } } // Preload next and previous images preloadImage(currentIndex + 1) preloadImage(currentIndex - 1) }, [currentIndex, items, getPreviewUrl, isVideo, preloadedImages]) // Fetch embedded metadata when item changes (only when metadata panel is shown) useEffect(() => { if (!showMetadata || !currentItem?.file_path) { setEmbeddedMetadata(null) return } const fetchEmbeddedMetadata = async () => { setEmbeddedMetadataLoading(true) try { const response = await fetch( `/api/media/embedded-metadata?file_path=${encodeURIComponent(currentItem.file_path)}`, { credentials: 'include' } ) if (response.ok) { const data = await response.json() // Only set if we got meaningful data if (data.title || data.artist || data.description || data.comment) { setEmbeddedMetadata(data) } else { setEmbeddedMetadata(null) } } } catch (error) { console.error('Failed to fetch embedded metadata:', error) setEmbeddedMetadata(null) } finally { setEmbeddedMetadataLoading(false) } } fetchEmbeddedMetadata() }, [currentItem?.file_path, showMetadata]) // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ignore if user is typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return switch (e.key) { case 'ArrowLeft': e.preventDefault() if (currentIndex > 0) onNavigate(currentIndex - 1) break case 'ArrowRight': e.preventDefault() if (currentIndex < items.length - 1) onNavigate(currentIndex + 1) break case 'Escape': e.preventDefault() onClose() break case 'Delete': e.preventDefault() if (onDelete && confirm(`Delete ${currentItem.filename || currentItem.original_filename}?`)) { onDelete(currentItem) } break case 'f': case 'F': e.preventDefault() toggleFullscreen() break case '+': case '=': e.preventDefault() setZoom(z => Math.min(z + 0.5, maxZoom)) break case '-': case '_': e.preventDefault() setZoom(z => Math.max(z - 0.5, 1)) break case '0': e.preventDefault() setZoom(1) setPan({ x: 0, y: 0 }) break case '1': // Jump to 100% actual size e.preventDefault() if (naturalSize && displayedSize) { const zoomFor100 = naturalSize.width / displayedSize.width setZoom(zoomFor100) } break } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [currentIndex, items.length, onNavigate, onClose, onDelete, currentItem, isVideo, maxZoom, naturalSize, displayedSize]) // Reset zoom/pan when changing items useEffect(() => { setZoom(1) setPan({ x: 0, y: 0 }) setImageLoading(true) setImageError(false) setNaturalSize(null) setDisplayedSize(null) }, [currentIndex]) // Check if image is already loaded (cached) - onLoad might not fire for cached images useEffect(() => { if (imageRef.current && !isVideo(currentItem)) { if (imageRef.current.complete && imageRef.current.naturalHeight !== 0) { // Image is already loaded from cache setImageLoading(false) setImageError(false) // Capture dimensions for cached images setNaturalSize({ width: imageRef.current.naturalWidth, height: imageRef.current.naturalHeight }) setDisplayedSize({ width: imageRef.current.clientWidth, height: imageRef.current.clientHeight }) } } }, [currentIndex, currentItem, isVideo]) // Mouse wheel zoom const handleWheel = useCallback((e: WheelEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault() const delta = e.deltaY > 0 ? -0.2 : 0.2 setZoom(z => Math.max(1, Math.min(maxZoom, z + delta))) } }, [maxZoom]) useEffect(() => { const container = containerRef.current if (container) { container.addEventListener('wheel', handleWheel, { passive: false }) return () => container.removeEventListener('wheel', handleWheel) } }, [handleWheel]) // Pan handling const handleMouseDown = (e: React.MouseEvent) => { if (zoom > 1 && !isVideo(currentItem)) { setIsPanning(true) setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }) } } const handleMouseMove = (e: React.MouseEvent) => { if (isPanning) { setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y, }) } } const handleMouseUp = () => { setIsPanning(false) } // Refs for direct DOM manipulation during gestures (avoids React re-renders) const gestureZoomRef = useRef(zoom) const gesturePanRef = useRef(pan) // Keep refs in sync when state changes from other sources (buttons, reset) useEffect(() => { gestureZoomRef.current = zoom gesturePanRef.current = pan }, [zoom, pan]) // Gesture handling with @use-gesture/react for smooth pinch-to-zoom useGesture( { onPinch: ({ offset: [scale], last }) => { if (isVideo(currentItem)) return const newZoom = Math.max(1, Math.min(maxZoom, scale)) gestureZoomRef.current = newZoom // Direct DOM update during gesture if (imageRef.current) { const p = gesturePanRef.current imageRef.current.style.transform = `scale3d(${newZoom}, ${newZoom}, 1) translate3d(${p.x / newZoom}px, ${p.y / newZoom}px, 0)` } // Sync to React state only on gesture end if (last) { setZoom(newZoom) } }, onDrag: ({ offset: [x, y], pinching, last }) => { if (isVideo(currentItem) || pinching) return const currentZoom = gestureZoomRef.current if (currentZoom <= 1) return gesturePanRef.current = { x, y } // Direct DOM update during gesture if (imageRef.current) { imageRef.current.style.transform = `scale3d(${currentZoom}, ${currentZoom}, 1) translate3d(${x / currentZoom}px, ${y / currentZoom}px, 0)` } // Sync to React state only on gesture end if (last) { setPan({ x, y }) } }, }, { target: imageRef, eventOptions: { passive: false }, pinch: { scaleBounds: { min: 1, max: maxZoom }, rubberband: true, }, drag: { from: () => [gesturePanRef.current.x, gesturePanRef.current.y], }, } ) // Toggle fullscreen const toggleFullscreen = () => { if (!document.fullscreenElement) { containerRef.current?.requestFullscreen() setIsFullscreen(true) } else { document.exitFullscreen() setIsFullscreen(false) } } // Quick actions const handleDownload = async () => { try { const url = getPreviewUrl(currentItem) const filename = currentItem.filename || currentItem.original_filename const response = await fetch(url) const blob = await response.blob() const downloadUrl = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = downloadUrl a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) window.URL.revokeObjectURL(downloadUrl) notificationManager.success('Download', 'File downloaded successfully', '📥') } catch (error) { notificationManager.error('Download Failed', 'Failed to download file', '❌') } } const handleCopyPath = () => { const path = currentItem.file_path || currentItem.original_file_path navigator.clipboard.writeText(path) notificationManager.success('Copied', 'File path copied to clipboard', '📋') } // Scroll thumbnail into view useEffect(() => { const thumbnailStrip = thumbnailStripRef.current if (thumbnailStrip) { const thumbnail = thumbnailStrip.children[currentIndex] as HTMLElement if (thumbnail) { thumbnail.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }) } } }, [currentIndex]) return (
{/* Top Bar - with safe area offset */}
e.stopPropagation()} >
{/* Title */}

{currentItem.filename || currentItem.original_filename}

{currentIndex + 1} / {items.length}

{/* Quick Actions */}
{/* Zoom controls - hidden on mobile, use pinch-to-zoom instead */} {!isVideo(currentItem) && ( <> {/* Current zoom percentage */} {naturalSize && displayedSize ? `${Math.round((displayedSize.width / naturalSize.width) * zoom * 100)}%` : `${Math.round(zoom * 100)}%`} {/* 100% button */} )}
{/* Navigation Buttons */} {/* Main Content Area */}
e.stopPropagation()}> {isVideo(currentItem) ? (
{/* Video Player */}
{/* Video Info Bar */}
{/* Channel/Source */} {currentItem.source && ( {currentItem.source} )} {/* Duration */} {(currentItem.duration || currentItem.metadata?.duration) && (currentItem.duration > 0 || currentItem.metadata?.duration > 0) && ( {formatDuration(currentItem.duration || currentItem.metadata?.duration)} )} {/* File Size */} {currentItem.file_size && ( {formatBytes(currentItem.file_size)} )} {/* Date */} {(currentItem.post_date || currentItem.download_date) && ( {formatDate(currentItem.post_date || currentItem.download_date)} )} {/* Resolution */} {(currentItem.width && currentItem.height) && ( {formatResolution(currentItem.width, currentItem.height)} )} {/* Platform - right aligned */} {currentItem.platform && ( <>
{formatPlatformName(currentItem.platform)} )}
) : (
{imageLoading && (
)} {imageError ? (

Failed to load image

) : ( {currentItem.filename { setImageLoading(false) const img = e.target as HTMLImageElement setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight }) setDisplayedSize({ width: img.clientWidth, height: img.clientHeight }) }} onError={() => { setImageLoading(false) setImageError(true) }} draggable={false} /> )}
{/* Image Info Bar */}
{/* Channel/Source */} {currentItem.source && ( {currentItem.source} )} {/* File Size */} {currentItem.file_size && ( {formatBytes(currentItem.file_size)} )} {/* Date */} {(currentItem.post_date || currentItem.download_date) && ( {formatDate(currentItem.post_date || currentItem.download_date)} )} {/* Resolution */} {(currentItem.width && currentItem.height) && ( {formatResolution(currentItem.width, currentItem.height)} )} {/* Platform - right aligned */} {currentItem.platform && ( <>
{formatPlatformName(currentItem.platform)} )}
)}
{/* Metadata Panel Card */}
e.stopPropagation()} >

Details

{/* Embedded Metadata Section */} {embeddedMetadataLoading && (
Loading metadata...
)} {embeddedMetadata?.title && (
Title
{embeddedMetadata.title}
)} {embeddedMetadata?.description && (
Description
{embeddedMetadata.description}
)} {/* Post content/description from item (e.g., paid content posts) */} {(currentItem.description || currentItem.post_content) && !embeddedMetadata?.description && (
Post Content
{currentItem.description || currentItem.post_content}
)} {(embeddedMetadata && (embeddedMetadata.title || embeddedMetadata.description)) || currentItem.description || currentItem.post_content ? (
) : null}
Filename
{currentItem.filename || currentItem.original_filename}
{(currentItem.width || currentItem.height || naturalSize) && (
Resolution
{currentItem.width && currentItem.height ? `${currentItem.width} x ${currentItem.height}` : naturalSize ? `${naturalSize.width} x ${naturalSize.height}` : 'Loading...'}
)} {currentItem.file_size && currentItem.file_size > 0 && (
Size
{formatBytes(currentItem.file_size)}
)} {currentItem.post_date && (
Post Date {onEditDate && ( )}
{formatDate(currentItem.post_date)}
)}
Download Date
{formatDate(currentItem.download_date || currentItem.added_date || currentItem.deleted_at)}
{currentItem.platform && (
Platform
{formatPlatformName(currentItem.platform)}
)} {currentItem.post_id && (
Post ID
{currentItem.post_id}
)} {currentItem.source && (
Source
{currentItem.source}
)} {currentItem.file_path && (
Path
{currentItem.file_path}
)} {currentItem.deleted_from && (
Deleted From
{currentItem.deleted_from === 'instagram_perceptual_duplicate_detection' ? 'Perceptual Duplicate' : currentItem.deleted_from.charAt(0).toUpperCase() + currentItem.deleted_from.slice(1)}
)} {!hideFaceRecognition && currentItem.face_recognition && (
Face Recognition
{currentItem.face_recognition.matched ? ( Matched: {currentItem.face_recognition.person_name} {currentItem.face_recognition.confidence && ( ({Math.round(currentItem.face_recognition.confidence * 100)}%) )} ) : ( {currentItem.face_recognition.confidence && currentItem.face_recognition.confidence > 0 ? ( <>No match ({Math.round(currentItem.face_recognition.confidence * 100)}%) ) : ( 'No match' )} )}
)}
{/* Metadata Toggle Button */} {/* Action Buttons */} {renderActions && (
{renderActions(currentItem)}
)} {/* Thumbnail Navigation Strip */}
e.stopPropagation()} >
{items.map((item, index) => ( ))}
) }