feat(photos): add 1h disk cache for remote thumbnails and keep tabs mounted

Closes #686

- Add trekPhotoCache service: SHA1-keyed disk cache under uploads/photos/trek/,
  1h TTL, in-flight dedup map to prevent stampedes on concurrent requests
- Add migration 108: trek_photo_cache_meta table
- Hook cache into streamPhoto for Immich/Synology thumbnail path;
  originals bypass cache
- Add fetchImmichThumbnailBytes / fetchSynologyThumbnailBytes returning
  Buffer instead of piping, used by the cache layer
- Add scheduler entry (every 2h + startup sweep) to evict expired disk
  files and DB rows via sweepExpired()
- Client: convert journey tab conditional-mount to hidden-toggle so
  img elements stay in DOM across tab switches, preventing redundant
  thumbnail requests on rapid tab changes
- Expose invalidateSize() on JourneyMapHandle; call it on map tab
  activation to fix Leaflet rendering in previously-hidden container
This commit is contained in:
jubnl
2026-04-17 20:49:38 +02:00
parent ae4dfc48cc
commit b5b1d32b31
9 changed files with 268 additions and 16 deletions
+6 -1
View File
@@ -14,6 +14,7 @@ export interface MapMarkerItem {
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void
invalidateSize: () => void
}
interface MapEntry {
@@ -151,7 +152,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
}
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
const invalidateSize = useCallback(() => {
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
useEffect(() => {
if (!containerRef.current) return
+22 -12
View File
@@ -164,6 +164,12 @@ export default function JourneyDetailPage() {
setActiveLocationId(id)
}, [])
useEffect(() => {
if (view === 'map') {
requestAnimationFrame(() => fullMapRef.current?.invalidateSize())
}
}, [view])
const mapEntries = useMemo(
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
[current?.entries]
@@ -413,8 +419,8 @@ export default function JourneyDetailPage() {
</div>
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && view === 'timeline' && (
<div className="flex flex-col gap-6 pb-24 md:pb-6">
{!isMobile && (
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
{sortedDates.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
@@ -469,7 +475,7 @@ export default function JourneyDetailPage() {
)}
{/* Gallery View */}
{view === 'gallery' && (
<div className={view === 'gallery' ? '' : 'hidden'}>
<GalleryView
entries={current.entries}
journeyId={current.id}
@@ -478,17 +484,21 @@ export default function JourneyDetailPage() {
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
/>
)}
</div>
{/* Full Map View (desktop only — mobile uses combined view) */}
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
activeLocationId={activeLocationId}
fullMapRef={fullMapRef}
onLocationClick={handleLocationClick}
/></div>}
{!isMobile && (
<div className={`pb-24 md:pb-6${view === 'map' ? '' : ' hidden'}`}>
<MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
activeLocationId={activeLocationId}
fullMapRef={fullMapRef}
onLocationClick={handleLocationClick}
/>
</div>
)}
</div>
{/* Right sidebar — hidden on mobile */}