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 }
+}