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 &&

)}
{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 (
)
}