diff --git a/client/src/components/Planner/PlacePhotoGallery.tsx b/client/src/components/Planner/PlacePhotoGallery.tsx new file mode 100644 index 000000000..56db6730a --- /dev/null +++ b/client/src/components/Planner/PlacePhotoGallery.tsx @@ -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 : 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( + , + document.body, + )} + + ) +} diff --git a/client/src/components/Planner/placeEnrichment.ts b/client/src/components/Planner/placeEnrichment.ts new file mode 100644 index 000000000..aefafdc9b --- /dev/null +++ b/client/src/components/Planner/placeEnrichment.ts @@ -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 +} + +/** + * 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() + +/** 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) +} diff --git a/client/src/components/Planner/placePhotoCredit.ts b/client/src/components/Planner/placePhotoCredit.ts new file mode 100644 index 000000000..ed472867a --- /dev/null +++ b/client/src/components/Planner/placePhotoCredit.ts @@ -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 +} diff --git a/client/src/components/Planner/usePlaceGallery.ts b/client/src/components/Planner/usePlaceGallery.ts new file mode 100644 index 000000000..feb4b34a0 --- /dev/null +++ b/client/src/components/Planner/usePlaceGallery.ts @@ -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(null) + const [isOpen, setIsOpen] = useState(false) + const [loading, setLoading] = useState(false) + + const selection = useMemo(() => { + 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(() => { + 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 } +}