feat(places): a photo gallery of a place, from its own picture and the pictures its sources know

The enrichment cache and the photo credit move out of the details column so
the view mode can reuse them, and usePlaceGallery gathers the custom image
and the provider candidates for a lightbox.
This commit is contained in:
gso
2026-10-23 18:31:14 +02:00
committed by Maurice
parent 47e04b506f
commit 357bcb72e3
4 changed files with 250 additions and 0 deletions
@@ -0,0 +1,31 @@
import React from 'react'
import { createPortal } from 'react-dom'
import PhotoLightbox from '../Journey/PhotoLightbox'
import { usePlaceGallery } from './usePlaceGallery'
import type { Place } from '../../types'
interface PlacePhotoGalleryProps {
place: Place
language: string
/** Receives the opener plus whether there is anything to open. */
children: (open: () => void, canOpen: boolean, loading: boolean) => React.ReactNode
}
/**
* Wraps a place avatar and portals the lightbox to <body>: the inspector and
* the phone sheet both sit inside transformed / stacking containers, where an
* in-place fixed overlay would be trapped.
*/
export default function PlacePhotoGallery({ place, language, children }: PlacePhotoGalleryProps): React.ReactElement {
const { photos, open, close, isOpen, loading, canOpen } = usePlaceGallery(place, language)
return (
<>
{children(open, canOpen, loading)}
{isOpen && photos.length > 0 && createPortal(
<PhotoLightbox photos={photos} onClose={close} />,
document.body,
)}
</>
)
}
@@ -0,0 +1,77 @@
import type { MapsPlaceEnrichmentResult } from '@trek/shared'
/**
* The place an enrichment consumer is describing — the editor's details column
* and the view-mode photo gallery both use this shape.
*/
export interface PlaceDetailsSelection {
placeId?: string
lat: number
lng: number
name: string
/** The picked search result, so the server can skip its own details lookup. */
details?: Record<string, unknown>
}
/**
* Module-level cache plus sessionStorage, same shape as usePlaceDetails in
* PlaceInspector. Clicking back and forth between two search results must not
* pay for the provider fan-out twice, and the gallery must reuse the pictures
* the editor already fetched for the same place.
*/
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>()
/** Test seam: the module-level cache otherwise leaks between cases. */
export function __clearEnrichmentCacheForTests(): void {
enrichmentCache.clear()
}
function readSession(key: string): MapsPlaceEnrichmentResult | undefined {
try {
const raw = sessionStorage.getItem(key)
return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined
} catch {
return undefined
}
}
function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch {
/* private mode / quota — the in-memory cache still helps for this session */
}
}
/**
* Bumped with the server's CACHE_VERSION. sessionStorage outlives a deploy, so
* without it the tab that was open while the fix shipped keeps replaying the
* answer the fix was about — and reports it as still broken.
*/
const ENRICH_CACHE_V = 4
export function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`
}
/** The answer if it was already fetched this session, else undefined. */
export function readCachedEnrichment(
selection: PlaceDetailsSelection,
language: string,
): MapsPlaceEnrichmentResult | undefined {
const key = cacheKeyFor(selection, language)
const cached = enrichmentCache.get(key) ?? readSession(key)
if (cached) enrichmentCache.set(key, cached)
return cached
}
export function storeEnrichment(
selection: PlaceDetailsSelection,
language: string,
value: MapsPlaceEnrichmentResult,
): void {
const key = cacheKeyFor(selection, language)
enrichmentCache.set(key, value)
writeSession(key, value)
}
@@ -0,0 +1,22 @@
import type { PlacePhotoCandidate } from '@trek/shared'
/** The name to put under a picture. Google gives no author, so it gets its own name. */
export function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source)
}
/**
* The provider a picture came from, for the pictures that hand us no author.
* Proper names, so they are not translated.
*/
export function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google'
if (source === 'wikipedia') return 'Wikipedia'
return 'Wikimedia Commons'
}
/** Author and licence joined for a single-line caption. */
export function photoCaption(photo: PlacePhotoCandidate): string {
const credit = creditOf(photo)
return photo.license ? `${credit} · ${photo.license}` : credit
}
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { PlacePhotoCandidate } from '@trek/shared'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useTranslation, translateApiError } from '../../i18n'
import { useToast } from '../shared/Toast'
import { photoCaption } from './placePhotoCredit'
import { readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment'
import type { Place } from '../../types'
/** What the lightbox renders; mirrors Journey/PhotoLightbox's private photo shape. */
export interface GalleryPhoto {
id: string
src: string
caption: string | null
}
interface UsePlaceGallery {
photos: GalleryPhoto[]
open: () => void
close: () => void
isOpen: boolean
/** Loading enrichment; the trigger can show a spinner while it waits. */
loading: boolean
/** False when there is no picture and nobody who could supply one. */
canOpen: boolean
}
/**
* The pictures of one place, for the full-screen view-mode gallery.
*
* The custom image (if any) leads, then the provider candidates the editor's
* details column already uses. Enrichment is fetched lazily on open: fanning
* out to Google/Wikimedia for every place a user merely selects is not worth
* it, and the shared session cache makes a second open instant.
*/
export function usePlaceGallery(place: Place, language: string): UsePlaceGallery {
const placesEnrichEnabled = useAuthStore((s) => s.placesEnrichEnabled)
const { t } = useTranslation()
const toast = useToast()
const [candidates, setCandidates] = useState<PlacePhotoCandidate[] | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const selection = useMemo<PlaceDetailsSelection | null>(() => {
if (place.lat == null || place.lng == null) return null
const lat = Number(place.lat)
const lng = Number(place.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
return {
placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined,
lat,
lng,
name: place.name || '',
}
}, [place.google_place_id, place.amap_poi_id, place.osm_id, place.lat, place.lng, place.name])
// A different place owns different pictures: drop the previous answer so the
// next open never flashes them.
useEffect(() => {
setCandidates(null)
setIsOpen(false)
}, [place.id])
const photos = useMemo<GalleryPhoto[]>(() => {
const list: GalleryPhoto[] = []
if (place.image_url) list.push({ id: 'custom', src: place.image_url, caption: null })
for (const candidate of candidates ?? []) {
if (list.some((p) => p.src === candidate.url)) continue
list.push({ id: candidate.key, src: candidate.url, caption: photoCaption(candidate) })
}
return list
}, [place.image_url, candidates])
const canOpen = !!place.image_url || (placesEnrichEnabled && !!selection)
const close = useCallback(() => setIsOpen(false), [])
const open = useCallback(() => {
// The custom image is already on screen — open now and let any provider
// pictures join it as they arrive.
if (place.image_url) setIsOpen(true)
if (!placesEnrichEnabled || !selection) {
if (!place.image_url) toast.info(t('places.details.noPhotos'))
return
}
if (candidates !== null) {
if (place.image_url || candidates.length) setIsOpen(true)
else toast.info(t('places.details.noPhotos'))
return
}
const apply = (list: PlacePhotoCandidate[]) => {
setCandidates(list)
if (place.image_url || list.length) setIsOpen(true)
else toast.info(t('places.details.noPhotos'))
}
const cached = readCachedEnrichment(selection, language)
if (cached) {
apply(cached.photos)
return
}
setLoading(true)
mapsApi
.placeEnrichment({ ...selection, lang: language })
.then((result) => {
storeEnrichment(selection, language, result)
apply(result.photos)
})
.catch((err: unknown) => {
if (!place.image_url) toast.error(translateApiError(t, err, 'places.details.error'))
})
.finally(() => setLoading(false))
}, [place.image_url, placesEnrichEnabled, selection, candidates, language, t, toast])
return { photos, open, close, isOpen, loading, canOpen }
}