Show place details beside the search field

Picking a search result fills a column on the left of the add-place dialog -
pictures and a description, no extra click. Someone who already knows the place
ignores it and saves exactly as before.

The column stays mounted with an empty state instead of appearing on the first
pick, otherwise the dialog jumps sideways mid-typing. Requests are cached in
memory and in sessionStorage and aborted when the selection changes, so clicking
back and forth between two results does not pay twice.

Every thumbnail carries its author, its licence and a link to the file page.
That is not decoration - most of these are CC BY-SA, and reusing one without
naming the author does not satisfy the licence. The description is shown but
never written into the place on its own; a button copies it across, and it is
disabled while the user already has a description of their own.

Desktop only. The mobile sheet is untouched.
This commit is contained in:
Maurice
2026-08-09 22:31:48 +02:00
committed by Maurice
parent f2368f86cf
commit 4843ebc937
5 changed files with 584 additions and 5 deletions
@@ -0,0 +1,225 @@
/**
* FE-PDC-001..015 — the detail column beside the add-place search field.
*
* mapsApi.placeEnrichment is mocked; these cover the load states, the caches,
* the abort on selection change, image picking and the attribution rendering
* that the Commons licences require.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import type { MapsPlaceEnrichmentResult } from '@trek/shared'
import PlaceDetailsColumn, { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './PlaceDetailsColumn'
const placeEnrichment = vi.fn()
vi.mock('../../api/client', () => ({ mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) } }))
/** Echo the key back, which keeps the assertions readable. */
const t = ((key: string) => key) as never
const SELECTION: PlaceDetailsSelection = { placeId: 'way:1', lat: 50.9, lng: 6.96, name: 'Museum Ludwig' }
const COMMONS_PHOTO = {
key: 'way:1~p0',
url: '/api/maps/place-photo/way%3A1~p0/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
sourceUrl: 'https://commons.wikimedia.org/wiki/File:X.jpg',
source: 'wikimedia' as const,
}
const GOOGLE_PHOTO = {
key: 'way:1~p1',
url: '/api/maps/place-photo/way%3A1~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google' as const,
}
const RESULT: MapsPlaceEnrichmentResult = {
photos: [COMMONS_PHOTO],
description: {
text: 'Ein Museum in Köln.',
source: 'wikipedia',
sourceUrl: 'https://de.wikipedia.org/wiki/Museum_Ludwig',
license: 'CC BY-SA 4.0',
},
}
function renderColumn(props: Partial<React.ComponentProps<typeof PlaceDetailsColumn>> = {}) {
const onPickImage = vi.fn()
const onAdoptDescription = vi.fn()
const utils = render(
<PlaceDetailsColumn
selection={SELECTION}
onPickImage={onPickImage}
onAdoptDescription={onAdoptDescription}
hasDescription={false}
language="de"
t={t}
{...props}
/>,
)
return { ...utils, onPickImage, onAdoptDescription }
}
beforeEach(() => {
placeEnrichment.mockReset()
placeEnrichment.mockResolvedValue(RESULT)
sessionStorage.clear()
__clearEnrichmentCacheForTests()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('PlaceDetailsColumn', () => {
it('FE-PDC-001: shows the empty state and calls nothing while nothing is selected', () => {
renderColumn({ selection: null })
expect(screen.getByText('places.details.empty')).toBeInTheDocument()
expect(placeEnrichment).not.toHaveBeenCalled()
})
it('FE-PDC-002: shows a loading state, then the result', async () => {
let settle: (v: MapsPlaceEnrichmentResult) => void = () => {}
placeEnrichment.mockReturnValue(new Promise<MapsPlaceEnrichmentResult>((r) => { settle = r }))
renderColumn()
expect(screen.getByText('places.details.loading')).toBeInTheDocument()
settle(RESULT)
await waitFor(() => expect(screen.getByText('Ein Museum in Köln.')).toBeInTheDocument())
})
it('FE-PDC-003: sends the selection and the language to the endpoint', async () => {
renderColumn()
await waitFor(() => expect(placeEnrichment).toHaveBeenCalled())
expect(placeEnrichment.mock.calls[0][0]).toEqual({
placeId: 'way:1',
lat: 50.9,
lng: 6.96,
name: 'Museum Ludwig',
lang: 'de',
})
})
it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
renderColumn()
const credit = await screen.findByText('Alice')
// Links to the file description page, where the full terms are.
expect(credit.closest('a')).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:X.jpg')
const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a' })
expect(licence).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/')
})
it('FE-PDC-005: names the source when a picture has no author, instead of inventing one', async () => {
placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null })
renderColumn()
expect(await screen.findByText('Google')).toBeInTheDocument()
})
it('FE-PDC-006: picking a picture reports its proxy URL', async () => {
const { onPickImage } = renderColumn()
fireEvent.click(await screen.findByRole('button', { name: /places.details.pickImage/ }))
expect(onPickImage).toHaveBeenCalledWith('/api/maps/place-photo/way%3A1~p0/bytes')
})
it('FE-PDC-007: clicking the picked picture again clears the hero image', async () => {
const { onPickImage } = renderColumn({ selectedImageUrl: COMMONS_PHOTO.url })
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(null)
})
it('FE-PDC-008: adopting the description hands the text up', async () => {
const { onAdoptDescription } = renderColumn()
fireEvent.click(await screen.findByRole('button', { name: 'places.details.adopt' }))
expect(onAdoptDescription).toHaveBeenCalledWith('Ein Museum in Köln.')
})
it('FE-PDC-009: will not overwrite a description the user already wrote', async () => {
renderColumn({ hasDescription: true })
expect(await screen.findByRole('button', { name: 'places.details.adopt' })).toBeDisabled()
})
it('FE-PDC-010: shows where the description came from', async () => {
renderColumn()
const link = await screen.findByRole('link', { name: /Wikipedia/ })
expect(link).toHaveAttribute('href', 'https://de.wikipedia.org/wiki/Museum_Ludwig')
// The photo credit carries the same licence string, so scope to the source
// line that sits directly under the description.
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
})
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
placeEnrichment.mockResolvedValue({ photos: [], description: null, disabled: true })
renderColumn()
expect(await screen.findByText('places.details.disabled')).toBeInTheDocument()
})
it('FE-PDC-012: says so when a place has no pictures', async () => {
placeEnrichment.mockResolvedValue({ photos: [], description: null })
renderColumn()
expect(await screen.findByText('places.details.noPhotos')).toBeInTheDocument()
})
it('FE-PDC-013: shows an error state when the request fails', async () => {
placeEnrichment.mockRejectedValue(new Error('network'))
renderColumn()
expect(await screen.findByText('places.details.error')).toBeInTheDocument()
})
it('FE-PDC-014: serves a second look at the same place from cache', async () => {
const first = renderColumn()
await screen.findByText('Ein Museum in Köln.')
first.unmount()
renderColumn()
await screen.findByText('Ein Museum in Köln.')
expect(placeEnrichment).toHaveBeenCalledTimes(1)
})
it('FE-PDC-015: aborts the pending request when the selection changes', async () => {
placeEnrichment.mockReturnValue(new Promise(() => {})) // never settles
const { rerender, onPickImage, onAdoptDescription } = renderColumn()
await waitFor(() => expect(placeEnrichment).toHaveBeenCalledTimes(1))
const firstSignal = placeEnrichment.mock.calls[0][1] as AbortSignal
expect(firstSignal.aborted).toBe(false)
rerender(
<PlaceDetailsColumn
selection={{ placeId: 'way:2', lat: 1, lng: 2, name: 'Somewhere else' }}
onPickImage={onPickImage}
onAdoptDescription={onAdoptDescription}
hasDescription={false}
language="de"
t={t}
/>,
)
await waitFor(() => expect(firstSignal.aborted).toBe(true))
expect(placeEnrichment).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,310 @@
import React, { useEffect, useRef, useState } from 'react'
import { Check, ImageOff, Loader2, ExternalLink } from 'lucide-react'
import type { MapsPlaceEnrichmentResult, PlacePhotoCandidate } from '@trek/shared'
import { mapsApi } from '../../api/client'
import type { TranslationFn } from '../../types'
/** The place the column is describing. Null while nothing is selected. */
export interface PlaceDetailsSelection {
placeId?: string
lat: number
lng: number
name: string
}
interface PlaceDetailsColumnProps {
selection: PlaceDetailsSelection | null
/** Currently chosen hero image, so the picked tile can show as picked. */
selectedImageUrl?: string
onPickImage: (url: string | null) => void
onAdoptDescription: (text: string) => void
/** True once the form's description field has something in it. */
hasDescription: boolean
language: string
t: TranslationFn
}
/**
* 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.
*/
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 */
}
}
function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`
return `enrich_${id}_${language}`
}
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
export default function PlaceDetailsColumn({
selection,
selectedImageUrl,
onPickImage,
onAdoptDescription,
hasDescription,
language,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
const [state, setState] = useState<LoadState>('idle')
const abortRef = useRef<AbortController | null>(null)
const selectionKey = selection ? cacheKeyFor(selection, language) : null
useEffect(() => {
// Abort whatever the previous selection started; its answer is no longer
// about the place on screen.
abortRef.current?.abort()
abortRef.current = null
if (!selection || !selectionKey) {
setData(null)
setState('idle')
return
}
const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey)
if (cached) {
enrichmentCache.set(selectionKey, cached)
setData(cached)
setState('ready')
return
}
const controller = new AbortController()
abortRef.current = controller
setState('loading')
setData(null)
mapsApi
.placeEnrichment(
{ placeId: selection.placeId, lat: selection.lat, lng: selection.lng, name: selection.name, lang: language },
controller.signal,
)
.then((result) => {
if (controller.signal.aborted) return
enrichmentCache.set(selectionKey, result)
writeSession(selectionKey, result)
setData(result)
setState('ready')
})
.catch((err: unknown) => {
if (controller.signal.aborted || (err as { code?: string })?.code === 'ERR_CANCELED') return
setState('error')
})
return () => controller.abort()
// selectionKey folds in the place id (or its coordinates) and the language,
// which is everything the answer depends on. Depending on `selection` itself
// would refetch whenever the object identity changes without the place
// having changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectionKey, language])
useEffect(() => () => abortRef.current?.abort(), [])
return (
<aside className="w-full sm:w-72 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
<div className="px-3 py-2 border-b border-edge">
<p className="text-caption font-medium text-content">{t('places.details.title')}</p>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-4">
{!selection && <p className="text-caption text-content-muted">{t('places.details.empty')}</p>}
{selection && state === 'loading' && (
<div className="flex items-center gap-2 text-caption text-content-muted">
<Loader2 className="w-4 h-4 animate-spin" />
{t('places.details.loading')}
</div>
)}
{selection && state === 'error' && <p className="text-caption text-content-muted">{t('places.details.error')}</p>}
{selection && state === 'ready' && data?.disabled && (
<p className="text-caption text-content-muted">{t('places.details.disabled')}</p>
)}
{selection && state === 'ready' && !data?.disabled && (
<>
<PhotoStrip
photos={data?.photos ?? []}
selectedImageUrl={selectedImageUrl}
onPickImage={onPickImage}
t={t}
/>
<DescriptionBlock
description={data?.description ?? null}
hasDescription={hasDescription}
onAdoptDescription={onAdoptDescription}
t={t}
/>
</>
)}
</div>
</aside>
)
}
function PhotoStrip({
photos,
selectedImageUrl,
onPickImage,
t,
}: {
photos: PlacePhotoCandidate[]
selectedImageUrl?: string
onPickImage: (url: string | null) => void
t: TranslationFn
}): React.ReactElement {
if (photos.length === 0) {
return (
<div className="flex items-center gap-2 text-caption text-content-muted">
<ImageOff className="w-4 h-4" />
{t('places.details.noPhotos')}
</div>
)
}
return (
<div className="space-y-2">
<p className="text-caption text-content-muted">{t('places.details.pickImage')}</p>
<div className="grid grid-cols-2 gap-2">
{photos.map((photo) => {
const picked = selectedImageUrl === photo.url
return (
<div key={photo.key} className="space-y-1">
<button
type="button"
onClick={() => onPickImage(picked ? null : photo.url)}
aria-pressed={picked}
aria-label={photo.attribution ? `${t('places.details.pickImage')} — ${photo.attribution}` : t('places.details.pickImage')}
className={`relative block w-full aspect-square rounded-lg overflow-hidden border-2 transition-colors ${
picked ? 'border-accent' : 'border-edge hover:border-content-muted'
}`}
>
<img src={photo.url} alt="" loading="lazy" className="w-full h-full object-cover" />
{picked && (
<span className="absolute top-1 right-1 rounded-full bg-accent p-0.5">
<Check className="w-3 h-3 text-white" />
</span>
)}
</button>
<PhotoCredit photo={photo} />
</div>
)
})}
</div>
</div>
)
}
/**
* Author and licence under every thumbnail.
*
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
* without naming its author and licence does not satisfy those terms. When a
* source hands us no author (Google), we say where it came from rather than
* inventing a credit.
*/
function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement {
const sourceLabel = photo.source === 'google' ? 'Google' : photo.source === 'wikipedia' ? 'Wikipedia' : 'Wikimedia Commons'
const credit = photo.attribution || sourceLabel
return (
<p className="text-caption leading-tight text-content-muted truncate" title={`${credit}${photo.license ? ` · ${photo.license}` : ''}`}>
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
{photo.license && (
<>
{' · '}
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</>
)}
</p>
)
}
function DescriptionBlock({
description,
hasDescription,
onAdoptDescription,
t,
}: {
description: MapsPlaceEnrichmentResult['description']
hasDescription: boolean
onAdoptDescription: (text: string) => void
t: TranslationFn
}): React.ReactElement | null {
if (!description) return null
const sourceLabel =
description.source === 'google' ? 'Google' : description.source === 'osm' ? 'OpenStreetMap' : 'Wikipedia'
return (
<div className="space-y-2">
<p className="text-caption text-content-muted">{t('places.details.description')}</p>
<p className="text-caption text-content whitespace-pre-line">{description.text}</p>
<p className="text-caption leading-tight text-content-muted">
{description.sourceUrl ? (
<a
href={description.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 hover:underline"
>
{sourceLabel}
<ExternalLink className="w-2.5 h-2.5" />
</a>
) : (
sourceLabel
)}
{description.license && ` · ${description.license}`}
</p>
<button
type="button"
onClick={() => onAdoptDescription(description.text)}
className="text-caption px-2 py-1 rounded-lg border border-edge text-content hover:bg-surface disabled:opacity-50"
disabled={hasDescription}
title={hasDescription ? t('places.details.adoptBlocked') : undefined}
>
{t('places.details.adopt')}
</button>
</div>
)
}
@@ -15,6 +15,10 @@ export interface PlaceFormData {
google_place_id?: string
google_ftid?: string
osm_id?: string
// Hero image picked from the detail column. Optional and absent from
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
// it, and places.service already writes image_url through on create/update.
image_url?: string
}
export function isGoogleMapsUrl(input: string): boolean {
@@ -7,6 +7,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -84,6 +85,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [showNewCategory, setShowNewCategory] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
// What the detail column is describing. Null until the user picks a result.
const [detailsSelection, setDetailsSelection] = useState<PlaceDetailsSelection | null>(null)
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -93,7 +96,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const acAbortRef = useRef<AbortController | null>(null)
const toast = useToast()
const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore()
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
@@ -267,6 +270,19 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
website: result.website || prev.website,
phone: result.phone || prev.phone,
}))
// The one point every pick flows through, so the detail column hangs here.
// A new pick drops whatever hero image belonged to the previous place.
const lat = Number(result.lat)
const lng = Number(result.lng)
if (Number.isFinite(lat) && Number.isFinite(lng)) {
setDetailsSelection({
placeId: result.google_place_id || result.osm_id || undefined,
lat,
lng,
name: result.name || '',
})
setForm(prev => ({ ...prev, image_url: undefined }))
}
setMapsResults([])
setMapsSearch('')
}
@@ -453,6 +469,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
t,
language,
hasMapsKey,
placesEnrichEnabled,
can,
tripObj,
canUploadFiles,
@@ -472,6 +489,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
hasTimeError,
handleSubmit,
duplicateWarning,
detailsSelection,
}
}
@@ -517,6 +535,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
t,
language,
hasMapsKey,
placesEnrichEnabled,
can,
tripObj,
canUploadFiles,
@@ -536,16 +555,22 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
hasTimeError,
handleSubmit,
duplicateWarning,
detailsSelection,
} = S
// Desktop + Collections addon → two columns (form + saved-place picker). Mobile
// Desktop + Collections addon → the saved-place picker on the right. Mobile
// always keeps the original single-column form untouched.
const twoColumn = !isMobile && collectionsEnabled
// The detail column sits on the left on desktop whenever enrichment is on. It
// stays mounted with the selection null rather than appearing on the first
// pick — otherwise the dialog would jump sideways mid-typing.
const showDetails = !isMobile && placesEnrichEnabled
const modalSize = isMobile ? 'lg' : showDetails && twoColumn ? '5xl' : showDetails || twoColumn ? '4xl' : 'lg'
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={place ? t('places.editPlace') : t('places.addPlace')}
size={twoColumn ? '3xl' : 'lg'}
size={modalSize}
footer={
<div className="flex justify-end gap-3">
<button
@@ -566,8 +591,19 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
}
>
<div className={twoColumn ? 'flex gap-5 items-stretch' : ''}>
<form onSubmit={handleSubmit} className={twoColumn ? 'flex-1 min-w-0 space-y-4' : 'space-y-4'} onPaste={handlePaste}>
<div className={twoColumn || showDetails ? 'flex gap-5 items-stretch' : ''}>
{showDetails && (
<PlaceDetailsColumn
selection={detailsSelection}
selectedImageUrl={form.image_url}
onPickImage={(url) => setForm(prev => ({ ...prev, image_url: url ?? undefined }))}
onAdoptDescription={(text) => setForm(prev => ({ ...prev, description: text }))}
hasDescription={!!form.description.trim()}
language={language}
t={t}
/>
)}
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-4' : 'space-y-4'} onPaste={handlePaste}>
{/* Place Search */}
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
{!hasMapsKey && (
+4
View File
@@ -9,6 +9,10 @@ const sizeClasses: Record<string, string> = {
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
'3xl': 'max-w-5xl',
// Wide enough for the add-place dialog to carry a detail column beside the
// form, and both together beside the collection picker.
'4xl': 'max-w-6xl',
'5xl': 'max-w-7xl',
}
interface ModalProps {