import { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' 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 { useMediaLightbox } from './useMediaLightbox' import VideoPlayer from '../Journey/VideoPlayerLazy' import { Tooltip } from '../shared/Tooltip' /** The round buttons on the dark backdrop, the same as the note preview's. */ const LIGHTBOX_BTN = 'grid h-9 w-9 place-items-center rounded-full bg-[rgba(255,255,255,0.12)] text-[rgba(255,255,255,0.85)] hover:bg-[rgba(255,255,255,0.22)]' // theme-lint-disable: the lightbox is dark in every scheme // 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 { file, imgSrc, fileIsVideo, hasPrev, hasNext, goPrev, goNext, onTouchStart, onTouchEnd } = useMediaLightbox({ files, index, onIndexChange: setIndex, onClose, }) if (!file) return null const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? ( ) : null // A portal, as the two document previews are. Rendered in place, the overlay // sits inside the trip page's stacking context, below the navbar's z-[200], // which then covered the header and its buttons. return createPortal(
{ if (e.target === e.currentTarget) onClose() }} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} > {/* Header */}
{file.original_name} {index + 1} / {files.length}
{/* Main image + nav */}
{ if (e.target === e.currentTarget) onClose() }}> {navBtn('left', goPrev, hasPrev)} {fileIsVideo ? (
) : ( imgSrc && {file.original_name} )} {navBtn('right', goNext, hasNext)}
{/* Thumbnail strip */} {files.length > 1 && (
{files.map((f, i) => ( setIndex(i)} /> ))}
)}
, document.body, ) } 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 void getAuthUrl(file.url, 'download').then(u => { if (current) setSrc(u) }) return () => { current = false } }, [file.url, fileIsVideo, visible]) return ( ) }