1052 lines
40 KiB
TypeScript
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>
|
|
)
|
|
}
|