import { useState, useEffect, useRef } from 'react' import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react' import { useTranslation } from '../../i18n' import type { TripFile } from '../../types' import { getAuthUrl } from '../../api/authUrl' import { openFile as openFileUrl } from '../../utils/fileDownload' import { triggerDownload, isVideo } from './FileManager.helpers' import VideoPlayer from '../Journey/VideoPlayerLazy' // Image lightbox with gallery navigation interface ImageLightboxProps { files: (TripFile & { url: string })[] initialIndex: number onClose: () => void } export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) { const { t } = useTranslation() const [index, setIndex] = useState(initialIndex) const [imgSrc, setImgSrc] = useState('') const [touchStart, setTouchStart] = useState(null) const file = files[index] const fileIsVideo = isVideo(file?.mime_type) useEffect(() => { setImgSrc('') // Images use a one-shot signed URL; a video must use the plain same-origin // URL (cookie auth) so its many Range requests all authenticate (#823). if (!file || isVideo(file.mime_type)) return // Arrowing through the gallery leaves several mints in flight; only the one for // the file still on screen may paint. let current = true getAuthUrl(file.url, 'download').then(u => { if (current) setImgSrc(u) }).catch(() => {}) return () => { current = false } }, [file?.url, file?.mime_type]) const goPrev = () => setIndex(i => Math.max(0, i - 1)) const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1)) useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() if (e.key === 'ArrowLeft') goPrev() if (e.key === 'ArrowRight') goNext() } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [onClose]) if (!file) return null const hasPrev = index > 0 const hasNext = index < files.length - 1 const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? ( ) : null return (
setTouchStart(e.touches[0].clientX)} onTouchEnd={e => { if (touchStart === null) return const diff = e.changedTouches[0].clientX - touchStart if (diff > 60) goPrev() else if (diff < -60) goNext() setTouchStart(null) }} > {/* Header */}
e.stopPropagation()}> {file.original_name} {index + 1} / {files.length}
{/* Main image + nav */}
{ if (e.target === e.currentTarget) onClose() }}> {navBtn('left', goPrev, hasPrev)} {fileIsVideo ? (
e.stopPropagation()}>
) : ( imgSrc && {file.original_name} e.stopPropagation()} /> )} {navBtn('right', goNext, hasNext)}
{/* Thumbnail strip */} {files.length > 1 && (
e.stopPropagation()}> {files.map((f, i) => ( setIndex(i)} /> ))}
)}
) } function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) { const fileIsVideo = isVideo(file.mime_type) const [src, setSrc] = useState('') const [visible, setVisible] = useState(false) const ref = useRef(null) // Each thumbnail costs its own one-shot download token, so the strip only // mints them for the thumbs that actually scroll into view. useEffect(() => { const el = ref.current if (!el || typeof IntersectionObserver !== 'function') { setVisible(true); return } const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' }) io.observe(el) return () => io.disconnect() }, []) // Videos have no stored thumbnail and can't render as an ; show a play // placeholder and don't mint a download token for them (#823). useEffect(() => { if (!visible || fileIsVideo) return let current = true getAuthUrl(file.url, 'download').then(u => { if (current) setSrc(u) }) return () => { current = false } }, [file.url, fileIsVideo, visible]) return ( ) }