mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user