import React, { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react' import Modal from '../shared/Modal' import { NumericInput } from '../shared/NumericInput' import MarkdownToolbar from '../Journey/MarkdownToolbar' import { mapsApi } from '../../api/client' import { collectionsApi } from '../../api/collections' import { getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { getApiErrorMessage } from '../../types' import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' import type { Category, TranslationFn } from '../../types' import type { CollectionLink, CollectionStatus } from '@trek/shared' type MapsPlace = Record const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined) interface AddPlaceToCollectionModalProps { isOpen: boolean collectionId: number collectionName: string categories: Category[] onClose: () => void onAdded: () => void t: TranslationFn } /** * Add a place to the current list — everything in one view: a search field that * fills in the location when a result is picked, plus name / category / status / * markdown description / links, all editable together before saving. Stays open * after each add so several places can be added in a row. */ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement { const { language } = useTranslation() const toast = useToast() const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) // A search that came back empty used to render nothing at all, which reads as // "the dialog is dead". Say so instead (#1921). const [noResults, setNoResults] = useState(false) // The picked location (address/coords/ids) plus the editable fields. const [picked, setPicked] = useState(null) const [name, setName] = useState('') // Address + coordinates: prefilled from a picked result, but also directly // typeable so a place can be added by GPS without searching (#1435). const [address, setAddress] = useState('') const [lat, setLat] = useState('') const [lng, setLng] = useState('') const [categoryId, setCategoryId] = useState(null) const [description, setDescription] = useState('') const [links, setLinks] = useState([]) const [status, setStatus] = useState('idea') const [saving, setSaving] = useState(false) const descRef = useRef(null) const searchRef = useRef(null) const reset = () => { setQuery(''); setResults([]); setNoResults(false); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') } useEffect(() => { if (!isOpen) reset() }, [isOpen]) const search = async () => { if (!query.trim()) return setSearching(true) setNoResults(false) try { const res = await mapsApi.search(query, language) const places = (res.places as MapsPlace[]) || [] setResults(places) setNoResults(places.length === 0) } catch (err) { toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) } finally { setSearching(false) } } const dismissResults = () => { setResults([]); setNoResults(false) } const pick = (r: MapsPlace) => { setPicked(r) setName(str(r.name) ?? '') setAddress(str(r.address) ?? '') const la = num(r.lat); const lo = num(r.lng) setLat(la != null ? String(la) : '') setLng(lo != null ? String(lo) : '') setResults([]); setNoResults(false); setQuery(str(r.name) ?? query) } const setLink = (i: number, patch: Partial) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) const save = async () => { const cleanName = name.trim() if (!cleanName) return const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url) const latNum = lat.trim() ? Number(lat) : NaN const lngNum = lng.trim() ? Number(lng) : NaN setSaving(true) try { const res = await collectionsApi.savePlace({ collection_id: collectionId, name: cleanName, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null, google_place_id: (picked && str(picked.google_place_id)) ?? null, google_ftid: (picked && str(picked.google_ftid)) ?? null, osm_id: (picked && str(picked.osm_id)) ?? null, website: (picked && str(picked.website)) ?? null, phone: (picked && str(picked.phone)) ?? null, category_id: categoryId, description: description.trim() || null, links: cleanLinks, status, force: true, }) if (res.duplicate) toast.info(t('collections.duplicateWarning')) else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() } reset() // The dialog stays open for the next place, so hand the caret back to the // search field. The add button the user just clicked goes disabled with the // cleared name, which drops the focus to and leaves the dialog dead // to the keyboard (#1921). searchRef.current?.focus() } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { setSaving(false) } } const coordPaste = (e: React.ClipboardEvent) => { const text = e.clipboardData.getData('text').trim() const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/) if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) } } const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent' return ( } >
{/* Search — picking a result fills the location below. Pinned to the top of the scrolling dialog body: the dialog stays open after an add, and the search row used to sit above the viewport once the form was scrolled, so it looked like the button had vanished (#1921). The negative margins let its background cover the body padding while it is stuck. */}
{ setQuery(e.target.value); setNoResults(false) }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }} placeholder={t('collections.addPlaceSearch')} className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" />
{(results.length > 0 || noResults) && (
{t('common.search')}
{noResults ? (
{t('planner.noPlacesFound')}
) : results.map((r, i) => ( ))}
)}
{/* Name */}
setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" />
{/* Address + coordinates — editable so a place can be added by GPS alone */}
setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={coordInputClass} />
{/* Status */}
{STATUS_ORDER.map(s => { const Icon = STATUS_META[s].icon const on = status === s return ( ) })}
{/* Category */} {categories.length > 0 && (
{categories.map(cat => { const Icon = getCategoryIcon(cat.icon ?? undefined) const on = categoryId === cat.id const col = cat.color || '#6366f1' return ( ) })}
)} {/* Description */}