Files
media-downloader/web/frontend/src/components/EnhancedLightbox.tsx
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

1052 lines
40 KiB
TypeScript

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<Set<number>>(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<HTMLDivElement>(null)
const imageRef = useRef<HTMLImageElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const thumbnailStripRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className="fixed inset-0 bg-black/90 z-50 flex flex-col"
style={{
touchAction: 'none',
// Safe area insets for iPhone notch
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
paddingLeft: 'env(safe-area-inset-left)',
paddingRight: 'env(safe-area-inset-right)',
}}
onClick={onClose}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Top Bar - with safe area offset */}
<div
className="absolute left-0 right-0 bg-gradient-to-b from-black/80 to-transparent p-4 z-10"
style={{ top: 'env(safe-area-inset-top, 0px)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between max-w-7xl mx-auto">
{/* Title */}
<div className="flex-1 min-w-0 text-white">
<h3 className="text-lg font-medium truncate">
{currentItem.filename || currentItem.original_filename}
</h3>
<p className="text-sm text-white/70">
{currentIndex + 1} / {items.length}
</p>
</div>
{/* Quick Actions */}
<div className="flex items-center gap-1 ml-2 sm:ml-4 flex-shrink-0">
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onPointerUp={(e) => { e.stopPropagation(); e.preventDefault(); handleDownload() }}
onClick={(e) => { e.stopPropagation(); handleDownload() }}
className="p-2.5 hover:bg-white/20 rounded-lg transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
title="Download (D)"
>
<Download className="w-5 h-5 text-white" />
</button>
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onPointerUp={(e) => { e.stopPropagation(); e.preventDefault(); handleCopyPath() }}
onClick={(e) => { e.stopPropagation(); handleCopyPath() }}
className="hidden sm:flex p-2.5 hover:bg-white/20 rounded-lg transition-colors min-w-[44px] min-h-[44px] items-center justify-center"
title="Copy Path"
>
<Copy className="w-5 h-5 text-white" />
</button>
{/* Zoom controls - hidden on mobile, use pinch-to-zoom instead */}
{!isVideo(currentItem) && (
<>
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onPointerUp={(e) => { e.stopPropagation(); e.preventDefault(); setZoom(z => Math.max(1, z - 0.5)) }}
onClick={(e) => { e.stopPropagation(); setZoom(z => Math.max(1, z - 0.5)) }}
disabled={zoom <= 1}
className="hidden sm:flex p-2.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-50 min-w-[44px] min-h-[44px] items-center justify-center"
title="Zoom Out (-)"
>
<ZoomOut className="w-5 h-5 text-white" />
</button>
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onPointerUp={(e) => { e.stopPropagation(); e.preventDefault(); setZoom(z => Math.min(maxZoom, z + 0.5)) }}
onClick={(e) => { e.stopPropagation(); setZoom(z => Math.min(maxZoom, z + 0.5)) }}
disabled={zoom >= maxZoom}
className="hidden sm:flex p-2.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-50 min-w-[44px] min-h-[44px] items-center justify-center"
title="Zoom In (+)"
>
<ZoomIn className="w-5 h-5 text-white" />
</button>
{/* Current zoom percentage */}
<span className="hidden sm:block text-white text-sm min-w-[3rem] text-center">
{naturalSize && displayedSize
? `${Math.round((displayedSize.width / naturalSize.width) * zoom * 100)}%`
: `${Math.round(zoom * 100)}%`}
</span>
{/* 100% button */}
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onPointerUp={(e) => {
e.stopPropagation()
e.preventDefault()
if (naturalSize && displayedSize) {
setZoom(naturalSize.width / displayedSize.width)
}
}}
onClick={(e) => {
e.stopPropagation()
if (naturalSize && displayedSize) {
setZoom(naturalSize.width / displayedSize.width)
}
}}
disabled={!naturalSize || !displayedSize || Math.abs((displayedSize.width / naturalSize.width) * zoom - 1) < 0.01}
className="hidden sm:block px-2 py-1 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-50 text-white text-sm font-medium"
title="Actual Size (1)"
>
100%
</button>
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onPointerUp={(e) => { e.stopPropagation(); e.preventDefault(); setZoom(1); setPan({ x: 0, y: 0 }) }}
onClick={(e) => { e.stopPropagation(); setZoom(1); setPan({ x: 0, y: 0 }) }}
disabled={zoom === 1 && pan.x === 0 && pan.y === 0}
className="hidden sm:flex p-2.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-50 min-w-[44px] min-h-[44px] items-center justify-center"
title="Reset View (0)"
>
<RotateCcw className="w-5 h-5 text-white" />
</button>
</>
)}
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onPointerUp={(e) => { e.stopPropagation(); e.preventDefault(); toggleFullscreen() }}
onClick={(e) => { e.stopPropagation(); toggleFullscreen() }}
className="p-2.5 hover:bg-white/20 rounded-lg transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
title="Fullscreen (F)"
>
{isFullscreen ? (
<Minimize className="w-5 h-5 text-white" />
) : (
<Maximize className="w-5 h-5 text-white" />
)}
</button>
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onPointerUp={(e) => {
e.stopPropagation()
e.preventDefault()
onClose()
}}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onClose()
}}
className="p-2.5 hover:bg-white/20 rounded-lg transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
title="Close (Esc)"
>
<X className="w-6 h-6 text-white" />
</button>
</div>
</div>
</div>
{/* Navigation Buttons */}
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (currentIndex > 0) onNavigate(currentIndex - 1)
}}
disabled={currentIndex === 0}
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-black/30 hover:bg-black/50 rounded-full text-white disabled:opacity-30 transition-colors z-10 min-w-[48px] min-h-[48px] flex items-center justify-center"
title="Previous (←)"
>
<ChevronLeft className="w-7 h-7" />
</button>
<button
type="button"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (currentIndex < items.length - 1) onNavigate(currentIndex + 1)
}}
disabled={currentIndex === items.length - 1}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-black/30 hover:bg-black/50 rounded-full text-white disabled:opacity-30 transition-colors z-10 min-w-[48px] min-h-[48px] flex items-center justify-center"
title="Next (→)"
>
<ChevronRight className="w-7 h-7" />
</button>
{/* Main Content Area */}
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 pt-16 sm:pt-20 pb-32 sm:pb-40" onClick={(e) => e.stopPropagation()}>
{isVideo(currentItem) ? (
<div className="relative flex flex-col items-center justify-center" style={{ touchAction: 'auto' }}>
{/* Video Player */}
<div className="relative bg-black rounded-xl overflow-hidden shadow-2xl" style={{ touchAction: 'auto' }}>
<video
ref={videoRef}
key={getPreviewUrl(currentItem)}
src={getPreviewUrl(currentItem)}
className="max-w-[90vw] max-h-[70vh] w-auto h-auto"
controls
autoPlay
playsInline
preload="auto"
// @ts-ignore - AirPlay attributes
x-webkit-airplay="allow"
airplay="allow"
onLoadStart={() => setImageLoading(true)}
onLoadedData={() => {
setImageLoading(false)
// Try to play unmuted first, if blocked by browser, try muted
if (videoRef.current) {
videoRef.current.play().catch(() => {
// Autoplay blocked - try muted (works on iOS)
if (videoRef.current) {
videoRef.current.muted = true
videoRef.current.play().catch(() => {
// Still blocked - give up, user can tap play
})
}
})
}
}}
onCanPlay={() => setImageLoading(false)}
onError={(e) => {
console.error('Video load error:', e)
setImageLoading(false)
}}
/>
</div>
{/* Video Info Bar */}
<div className="mt-2 sm:mt-4 flex flex-wrap items-center gap-2 sm:gap-4 text-white/80 text-xs sm:text-sm">
{/* Channel/Source */}
{currentItem.source && (
<span className="flex items-center gap-1">
<Film className="w-3 h-3 sm:w-4 sm:h-4" />
{currentItem.source}
</span>
)}
{/* Duration */}
{(currentItem.duration || currentItem.metadata?.duration) && (currentItem.duration > 0 || currentItem.metadata?.duration > 0) && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
{formatDuration(currentItem.duration || currentItem.metadata?.duration)}
</span>
)}
{/* File Size */}
{currentItem.file_size && (
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3 sm:w-4 sm:h-4" />
{formatBytes(currentItem.file_size)}
</span>
)}
{/* Date */}
{(currentItem.post_date || currentItem.download_date) && (
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3 sm:w-4 sm:h-4" />
{formatDate(currentItem.post_date || currentItem.download_date)}
</span>
)}
{/* Resolution */}
{(currentItem.width && currentItem.height) && (
<span className="flex items-center gap-1 text-emerald-400 font-medium">
<Film className="w-3 h-3 sm:w-4 sm:h-4" />
{formatResolution(currentItem.width, currentItem.height)}
</span>
)}
{/* Platform - right aligned */}
{currentItem.platform && (
<>
<div className="hidden sm:block flex-1" />
<span className="text-indigo-400 font-medium">
{formatPlatformName(currentItem.platform)}
</span>
</>
)}
</div>
</div>
) : (
<div className="relative flex flex-col items-center justify-center">
<div
className="relative"
onMouseDown={handleMouseDown}
style={{
touchAction: 'none',
}}
>
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin" />
</div>
)}
{imageError ? (
<div className="text-white text-center">
<p className="mb-4">Failed to load image</p>
<button
onClick={() => {
setImageError(false)
setImageLoading(true)
}}
className="px-4 py-2 bg-white/20 hover:bg-white/30 rounded transition-colors"
>
Retry
</button>
</div>
) : (
<img
ref={imageRef}
key={getPreviewUrl(currentItem)}
src={getPreviewUrl(currentItem)}
alt={currentItem.filename || currentItem.original_filename}
className="max-w-full max-h-[65vh] object-contain rounded-lg select-none"
style={{
transform: `scale3d(${zoom}, ${zoom}, 1) translate3d(${pan.x / zoom}px, ${pan.y / zoom}px, 0)`,
transformOrigin: 'center',
opacity: imageLoading ? 0 : 1,
transition: 'opacity 0.2s',
WebkitTouchCallout: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
} as React.CSSProperties}
onLoad={(e) => {
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}
/>
)}
</div>
{/* Image Info Bar */}
<div className="mt-2 sm:mt-4 flex flex-wrap items-center gap-2 sm:gap-4 text-white/80 text-xs sm:text-sm">
{/* Channel/Source */}
{currentItem.source && (
<span className="flex items-center gap-1">
<Film className="w-3 h-3 sm:w-4 sm:h-4" />
{currentItem.source}
</span>
)}
{/* File Size */}
{currentItem.file_size && (
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3 sm:w-4 sm:h-4" />
{formatBytes(currentItem.file_size)}
</span>
)}
{/* Date */}
{(currentItem.post_date || currentItem.download_date) && (
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3 sm:w-4 sm:h-4" />
{formatDate(currentItem.post_date || currentItem.download_date)}
</span>
)}
{/* Resolution */}
{(currentItem.width && currentItem.height) && (
<span className="flex items-center gap-1 text-emerald-400 font-medium">
<Film className="w-3 h-3 sm:w-4 sm:h-4" />
{formatResolution(currentItem.width, currentItem.height)}
</span>
)}
{/* Platform - right aligned */}
{currentItem.platform && (
<>
<div className="hidden sm:block flex-1" />
<span className="text-indigo-400 font-medium">
{formatPlatformName(currentItem.platform)}
</span>
</>
)}
</div>
</div>
)}
</div>
{/* Metadata Panel Card */}
<div
className={`absolute right-6 bottom-32 w-80 transition-all duration-300 ease-in-out ${
showMetadata ? 'translate-x-0 opacity-100' : 'translate-x-[110%] opacity-0'
}`}
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 6rem)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="h-full bg-gradient-to-br from-gray-900/95 to-black/95 backdrop-blur-xl rounded-2xl border border-white/30 shadow-[0_20px_60px_rgba(0,0,0,0.8)] overflow-hidden">
<div className="p-6 h-full overflow-y-auto">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/20">
<h3 className="text-white font-semibold text-lg">Details</h3>
</div>
<div className="space-y-3 text-sm">
{/* Embedded Metadata Section */}
{embeddedMetadataLoading && (
<div className="text-white/40 text-xs italic">Loading metadata...</div>
)}
{embeddedMetadata?.title && (
<div>
<div className="text-white/60 mb-1 flex items-center gap-2">
<Film className="w-4 h-4" />
Title
</div>
<div className="text-white">
{embeddedMetadata.title}
</div>
</div>
)}
{embeddedMetadata?.description && (
<div>
<div className="text-white/60 mb-1">Description</div>
<div className="text-white text-xs max-h-24 overflow-y-auto whitespace-pre-wrap">
{embeddedMetadata.description}
</div>
</div>
)}
{/* Post content/description from item (e.g., paid content posts) */}
{(currentItem.description || currentItem.post_content) && !embeddedMetadata?.description && (
<div>
<div className="text-white/60 mb-1">Post Content</div>
<div className="text-white text-xs max-h-32 overflow-y-auto whitespace-pre-wrap">
{currentItem.description || currentItem.post_content}
</div>
</div>
)}
{(embeddedMetadata && (embeddedMetadata.title || embeddedMetadata.description)) || currentItem.description || currentItem.post_content ? (
<div className="border-t border-white/10 pt-3 mt-3" />
) : null}
<div>
<div className="text-white/60 mb-1 flex items-center gap-2">
<ImageIcon className="w-4 h-4" />
Filename
</div>
<div className="text-white break-all">
{currentItem.filename || currentItem.original_filename}
</div>
</div>
{(currentItem.width || currentItem.height || naturalSize) && (
<div>
<div className="text-white/60 mb-1 flex items-center gap-2">
<ImageIcon className="w-4 h-4" />
Resolution
</div>
<div className="text-white">
{currentItem.width && currentItem.height
? `${currentItem.width} x ${currentItem.height}`
: naturalSize
? `${naturalSize.width} x ${naturalSize.height}`
: 'Loading...'}
</div>
</div>
)}
{currentItem.file_size && currentItem.file_size > 0 && (
<div>
<div className="text-white/60 mb-1 flex items-center gap-2">
<HardDrive className="w-4 h-4" />
Size
</div>
<div className="text-white">
{formatBytes(currentItem.file_size)}
</div>
</div>
)}
{currentItem.post_date && (
<div>
<div className="text-white/60 mb-1 flex items-center gap-2">
<CalendarClock className="w-4 h-4" />
Post Date
{onEditDate && (
<button
onClick={() => onEditDate(currentItem)}
className="ml-auto p-1 hover:bg-white/20 rounded transition-colors"
title="Edit Post Date"
>
<Pencil className="w-3.5 h-3.5 text-amber-400" />
</button>
)}
</div>
<div className="text-white">
{formatDate(currentItem.post_date)}
</div>
</div>
)}
<div>
<div className="text-white/60 mb-1 flex items-center gap-2">
<Calendar className="w-4 h-4" />
Download Date
</div>
<div className="text-white">
{formatDate(currentItem.download_date || currentItem.added_date || currentItem.deleted_at)}
</div>
</div>
{currentItem.platform && (
<div>
<div className="text-white/60 mb-1">Platform</div>
<div className="text-white">{formatPlatformName(currentItem.platform)}</div>
</div>
)}
{currentItem.post_id && (
<div>
<div className="text-white/60 mb-1">Post ID</div>
<div className="text-white font-mono">{currentItem.post_id}</div>
</div>
)}
{currentItem.source && (
<div>
<div className="text-white/60 mb-1">Source</div>
<div className="text-white">{currentItem.source}</div>
</div>
)}
{currentItem.file_path && (
<div>
<div className="text-white/60 mb-1">Path</div>
<div className="text-white text-xs break-all font-mono">
{currentItem.file_path}
</div>
</div>
)}
{currentItem.deleted_from && (
<div>
<div className="text-white/60 mb-1">Deleted From</div>
<div className="text-white">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
currentItem.deleted_from === 'media' ? 'bg-blue-500/20 text-blue-300 border border-blue-400/30' :
currentItem.deleted_from === 'review' ? 'bg-yellow-500/20 text-yellow-300 border border-yellow-400/30' :
currentItem.deleted_from === 'instagram_perceptual_duplicate_detection' ? 'bg-red-500/20 text-red-300 border border-red-400/30' :
'bg-purple-500/20 text-purple-300 border border-purple-400/30'
}`}>
{currentItem.deleted_from === 'instagram_perceptual_duplicate_detection'
? 'Perceptual Duplicate'
: currentItem.deleted_from.charAt(0).toUpperCase() + currentItem.deleted_from.slice(1)}
</span>
</div>
</div>
)}
{!hideFaceRecognition && currentItem.face_recognition && (
<div>
<div className="text-white/60 mb-1 flex items-center gap-2">
<Eye className="w-4 h-4" />
Face Recognition
</div>
<div className="text-white">
{currentItem.face_recognition.matched ? (
<span className="text-green-400">
Matched: {currentItem.face_recognition.person_name}
{currentItem.face_recognition.confidence && (
<span className="text-white/60 ml-2">
({Math.round(currentItem.face_recognition.confidence * 100)}%)
</span>
)}
</span>
) : (
<span className="text-red-400">
{currentItem.face_recognition.confidence && currentItem.face_recognition.confidence > 0 ? (
<>No match ({Math.round(currentItem.face_recognition.confidence * 100)}%)</>
) : (
'No match'
)}
</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Metadata Toggle Button */}
<button
onClick={(e) => {
e.stopPropagation()
setShowMetadata(!showMetadata)
}}
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 6rem)' }}
className="absolute right-4 p-3 bg-black/30 hover:bg-black/50 rounded-lg text-white transition-colors z-10 min-w-[44px] min-h-[44px] flex items-center justify-center"
title="Toggle Metadata"
>
{showMetadata ? (
<ChevronRight className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5 rotate-90" />
)}
</button>
{/* Action Buttons */}
{renderActions && (
<div className="absolute bottom-28 left-1/2 -translate-x-1/2 flex items-center gap-3">
{renderActions(currentItem)}
</div>
)}
{/* Thumbnail Navigation Strip */}
<div
className="absolute left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4 pt-8"
style={{ bottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={(e) => e.stopPropagation()}
>
<div
ref={thumbnailStripRef}
className="flex gap-2 overflow-x-auto overflow-y-visible scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent pt-1 pb-2 px-1"
style={{ scrollbarWidth: 'thin' }}
>
{items.map((item, index) => (
<button
key={index}
onClick={() => onNavigate(index)}
className={`relative flex-shrink-0 w-24 h-14 bg-slate-800 rounded-lg overflow-hidden transition-all ${
index === currentIndex
? 'ring-2 ring-red-500 opacity-100 scale-105'
: 'opacity-60 hover:opacity-100'
}`}
style={{ WebkitTouchCallout: 'none' } as React.CSSProperties}
>
<img
src={getThumbnailUrl(item)}
alt=""
className="w-full h-full object-cover"
/>
{isVideo(item) && (
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
<Play className="w-4 h-4 text-white/80" />
</div>
)}
</button>
))}
</div>
</div>
</div>
)
}