mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user