Files
TREK/client/src/mobile/screens/trip/tabs/MFileLightbox.tsx
T
Maurice c089e75f08 perf(map): load one GL engine instead of both (#1819)
* perf(map): load one GL engine instead of both

MapViewGL, JourneyMapGL and the settings preview each imported mapbox-gl and
maplibre-gl statically, so rollup emitted one 2.83 MB chunk carrying both.
Every map user downloaded 1.8 MB of mapbox plus 1.03 MB of maplibre and then
ran exactly one of them. The 110 kB stylesheet was merged the same way.

  mapbox users     2,827,647 -> 1,797,952 B   (-1.03 MB, CSS 110.6 -> 40.8 kB)
  maplibre users   2,827,647 -> 1,027,635 B   (-1.80 MB, CSS 110.6 -> 69.8 kB)

The engine is a prop now rather than a module import, and the pairing happens
behind the lazy boundary in glLazy.tsx. No module reaches both SDKs statically
any more, which is the whole mechanism — the components themselves stayed
engine-agnostic, they already guarded on the provider everywhere the two
genuinely differ.

Both imports of a pair start together instead of in sequence: the engine is by
far the larger half, and fetching the component first would put its round trip
in front of the download that actually costs time.

check:gl-split guards it. A stray static import anywhere in the map tree brings
the merged chunk straight back with a green build and a working app, so the
script greps the built chunks for a marker of each SDK and fails when one file
holds both. Not on the bare string "maplibre" — the provider value and the
maplibre_style setting key appear in app code too, so that matches the shared
core chunk as well.

Two boundaries were missing and are added here, because the split makes them
reachable: the GL boundaries had no resetKeys, so a chunk failure under one
provider kept showing Leaflet after switching to the other, and the settings
preview had a Suspense but no boundary at all — that is the one place a user
flips providers live, so it is the likeliest chunk to fail.

Worth being precise about the gain: the service worker precaches every chunk,
so an installed PWA still downloads both engines. What this buys is the
critical path on a cold cache, and parse/compile/execute always — a megabyte
of unused engine ran on every map open.

* perf(client): split out the video player and the ISO subdivision data

Two heavyweights that shipped with routes that rarely use them.

plyr (112 kB raw / 33 kB gzip, plus 32 kB of CSS) hung off three lightboxes and
through them off the FileManager, JournalBody and MTripShell chunks. The
lightboxes only render the player on their video branch anyway, so the split
costs one frame of placeholder — a black rectangle in the target geometry, so
the lightbox does not jump when the chunk lands.

iso-3166-2 is a single 238 kB data blob with no tree-shaking to be had, and
only fetchRegionOptions touches it. It now loads after the hasRegions check,
so it is fetched only for countries that get a region picker at all:
holidayRegions drops from 257 kB to 14 kB. The import sits inside the existing
try — a failed chunk yields the same empty array as a failed request, rather
than an unhandled rejection in the two callers that only do .then(setRegions).

tz-lookup is deliberately left alone. TransitSearchPanel builds the departure
time it sends to MOTIS out of tzAt(), so a not-yet-loaded window there is a
wrong query rather than a late pixel — that one needs its own change with its
own care.
2026-08-07 12:46:03 +02:00

159 lines
6.0 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { ChevronLeft, ChevronRight, Download, ExternalLink, X } from 'lucide-react'
import { getAuthUrl } from '../../../../api/authUrl'
import { downloadFile, openFile } from '../../../../utils/fileDownload'
import { isVideo } from '../../../../components/Files/FileManager.helpers'
import VideoPlayer from '../../../../components/Journey/VideoPlayerLazy'
import type { TranslationFn, TripFile } from '../../../../types'
interface MFileLightboxProps {
files: TripFile[]
index: number
onIndexChange: (index: number) => void
onClose: () => void
t: TranslationFn
}
function sheetRoot(): HTMLElement {
return document.getElementById('m-sheet-root') ?? document.body
}
/**
* Media viewer for the Files tab (spec 03 §5.3 f.openF, image/video branch):
* a v1-simple version of the desktop FileManagerImageLightbox — full-screen
* dark overlay, prev/next + swipe, download/open-in-tab, no thumbnail strip
* (kept out deliberately, see report). Images resolve through the same
* one-shot signed URL as desktop; video keeps the plain same-origin URL so
* its Range requests stay cookie-authenticated (#823).
*/
export default function MFileLightbox({ files, index, onIndexChange, onClose, t }: MFileLightboxProps) {
const file = files[index]
const [imgSrc, setImgSrc] = useState('')
const touchStartRef = useRef<number | null>(null)
const fileIsVideo = isVideo(file?.mime_type)
const fileUrl = file?.url
const fileMimeType = file?.mime_type
useEffect(() => {
setImgSrc('')
if (fileUrl && !isVideo(fileMimeType)) getAuthUrl(fileUrl, 'download').then(setImgSrc)
}, [fileUrl, fileMimeType])
const hasPrev = index > 0
const hasNext = index < files.length - 1
const goPrev = () => { if (hasPrev) onIndexChange(index - 1) }
const goNext = () => { if (hasNext) onIndexChange(index + 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)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index, files.length])
useEffect(() => {
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prevOverflow }
}, [])
if (!file) return null
return ReactDOM.createPortal(
<div
className="m-root fixed inset-0 z-[65] flex flex-col bg-black/[.92]"
onClick={onClose}
onTouchStart={e => { touchStartRef.current = e.touches[0].clientX }}
onTouchEnd={e => {
const start = touchStartRef.current
if (start === null) return
const diff = e.changedTouches[0].clientX - start
if (diff > 60) goPrev()
else if (diff < -60) goNext()
touchStartRef.current = null
}}
>
{/* Header */}
<div className="flex flex-none items-center justify-between px-4 py-[10px]" onClick={e => e.stopPropagation()}>
<span className="min-w-0 flex-1 truncate font-geist text-[0.75rem] text-white/70">
{file.original_name}
<span className="ml-2 text-white/40">{index + 1} / {files.length}</span>
</span>
<div className="flex flex-none items-center gap-1">
<button
type="button"
onClick={() => { openFile(file.url, file.original_name).catch(() => {}) }}
aria-label={t('files.openTab')}
className="flex h-8 w-8 items-center justify-center text-white/70"
>
<ExternalLink size={16} strokeWidth={2} />
</button>
<button
type="button"
onClick={() => { downloadFile(file.url, file.original_name).catch(() => {}) }}
aria-label={t('files.download')}
className="flex h-8 w-8 items-center justify-center text-white/70"
>
<Download size={16} strokeWidth={2} />
</button>
<button type="button" onClick={onClose} aria-label={t('common.close')} className="flex h-8 w-8 items-center justify-center text-white/70">
<X size={18} strokeWidth={2.2} />
</button>
</div>
</div>
{/* Media + nav */}
<div
className="relative flex min-h-0 flex-1 items-center justify-center"
onClick={e => {
if (e.target !== e.currentTarget) return
// Without this the click also reaches the overlay handler above.
e.stopPropagation()
onClose()
}}
>
{hasPrev && (
<button
type="button"
onClick={e => { e.stopPropagation(); goPrev() }}
aria-label={t('mobileTrip.filesPrev')}
className="absolute left-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white/80"
>
<ChevronLeft size={22} strokeWidth={2} />
</button>
)}
{fileIsVideo ? (
<div onClick={e => e.stopPropagation()}>
<VideoPlayer src={file.url} style={{ maxWidth: '92vw', maxHeight: '78vh', borderRadius: 8 }} />
</div>
) : (
imgSrc && (
<img
src={imgSrc}
alt={file.original_name}
onClick={e => e.stopPropagation()}
className="block max-h-[78vh] max-w-[92vw] rounded-lg object-contain"
/>
)
)}
{hasNext && (
<button
type="button"
onClick={e => { e.stopPropagation(); goNext() }}
aria-label={t('mobileTrip.filesNext')}
className="absolute right-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white/80"
>
<ChevronRight size={22} strokeWidth={2} />
</button>
)}
</div>
</div>,
sheetRoot(),
)
}