import React, { useEffect, useId, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Bookmark, Search, MapPin, MapPinned, Loader2, Trash2, Check, X } from 'lucide-react' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import { AddRowButton, EditorField, GRID_2, INPUT, PANEL, Segmented, TEXTAREA } from '../shared/dialogParts' import { NumericInput } from '../shared/NumericInput' import NoteFormatToolbar from '../shared/NoteFormatToolbar' import { Tooltip } from '../shared/Tooltip' 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) /** * The search row, pinned to the top of the scrolling body. The dialog stays open * after an add, and the 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's padding while it is stuck. */ const STICKY_SEARCH = 'sticky top-0 z-20 -mx-6 -mt-5 bg-surface-card px-6 pb-1 pt-5' /** A chip of the category row; the picked one wears the category's own colour. */ const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold' const CHIP_OFF = 'border-edge-faint bg-surface-card text-content-muted hover:text-content' const CHIP_NONE_ON = 'border-content bg-surface-card text-content' const ROW_ACTION = 'grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-secondary' 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: the name typed into * the head band, a search that fills it and the location in when a result is * picked, plus status, category, a markdown description and 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 labelId = useId() const fieldId = useId() 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) : Number.NaN const lngNum = lng.trim() ? Number(lng) : Number.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() // Same pairs as before, written so no two quantifiers can claim the same // character. In the old form `\d+\.?\d*` and `\s*[,;\s]\s*` were both ambiguous, // which backtracks in O(n^4): a pasted 2 kB of digits and spaces froze the tab. const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/) if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) } } const header = ( } tint={NEUTRAL_TINT} labelId={labelId} onClose={onClose} eyebrow={t('collections.addPlace')} // Typed into the band, as in the planner's place dialog. It takes no focus // on opening: the dialog starts at the search, which fills the name in. titleInput={{ value: name, onChange: setName, label: t('common.name'), placeholder: t('common.name') }} sub={<>{collectionName}} /> ) const footer = ( {t('common.cancel')} void save()} disabled={saving || !name.trim()} icon={saving ? : } > {t('common.add')} ) return ( {/* Search: picking a result fills the name and the location below. */}
void search()} disabled={!query.trim() || searching} icon={searching ? : } > {t('common.search')}
{(results.length > 0 || noResults) && (
{t('common.search')}
{noResults ? (
{t('planner.noPlacesFound')}
) : results.map((r, i) => ( ))}
)}
{/* Where it is: filled in by a picked result, but also typeable, so a place can be added by GPS alone (#1435). */}
setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={INPUT} />
label={t('mobileCollections.status')} value={status} onChange={setStatus} options={STATUS_ORDER.map(s => { const Icon = STATUS_META[s].icon return { value: s, label: t(STATUS_META[s].labelKey), icon: } })} /> {categories.length > 0 && (
{categories.map(cat => { const Icon = getCategoryIcon(cat.icon ?? undefined) const on = categoryId === cat.id const col = cat.color || '#6366f1' // theme-lint-disable: the category's own colour, and the default one without a colour is drawn in return ( ) })}
)} {t('collections.description')}} action={} >