diff --git a/client/src/components/Collections/AddPlaceToCollectionModal.tsx b/client/src/components/Collections/AddPlaceToCollectionModal.tsx index beb039588..6c7cd353f 100644 --- a/client/src/components/Collections/AddPlaceToCollectionModal.tsx +++ b/client/src/components/Collections/AddPlaceToCollectionModal.tsx @@ -1,12 +1,18 @@ -import React, { useEffect, useState } from 'react' -import { Search, MapPin, Plus, Loader2 } from 'lucide-react' +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, ArrowLeft, Link2, Trash2, Check } from 'lucide-react' import Modal from '../shared/Modal' +import MarkdownToolbar from '../Journey/MarkdownToolbar' import { mapsApi } from '../../api/client' import { collectionsApi } from '../../api/collections' 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 { 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) @@ -22,9 +28,10 @@ interface AddPlaceToCollectionModalProps { } /** - * Search for a place (OSM / Google via the maps service) and save it straight - * into the current list. Stays open after each add so several places can be - * added in one go. + * Add a place to the current list. Step 1: search (OSM / Google via the maps + * service). Step 2: an optional detail form (description in markdown, links, + * status) prefilled from the pick, then save. Stays open after each add so + * several places can be added in a row. */ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement { const { language } = useTranslation() @@ -32,10 +39,16 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) - const [addingIdx, setAddingIdx] = useState(null) + // Step 2 draft — the picked place plus the editable detail fields. + const [draft, setDraft] = useState(null) + const [description, setDescription] = useState('') + const [links, setLinks] = useState([]) + const [status, setStatus] = useState('idea') + const [saving, setSaving] = useState(false) + const descRef = useRef(null) useEffect(() => { - if (!isOpen) { setQuery(''); setResults([]); setAddingIdx(null) } + if (!isOpen) { setQuery(''); setResults([]); setDraft(null) } }, [isOpen]) const search = async () => { @@ -51,71 +64,138 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec } } - const add = async (r: MapsPlace, idx: number) => { - const name = str(r.name) + const pick = (r: MapsPlace) => { setDraft(r); setDescription(''); setLinks([]); setStatus('idea') } + const setLink = (i: number, patch: Partial) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) + + const save = async () => { + if (!draft) return + const name = str(draft.name) if (!name) return - setAddingIdx(idx) + const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url) + setSaving(true) try { const res = await collectionsApi.savePlace({ collection_id: collectionId, name, - address: str(r.address) ?? null, - lat: num(r.lat) ?? null, - lng: num(r.lng) ?? null, - google_place_id: str(r.google_place_id) ?? null, - google_ftid: str(r.google_ftid) ?? null, - osm_id: str(r.osm_id) ?? null, - website: str(r.website) ?? null, - phone: str(r.phone) ?? null, + address: str(draft.address) ?? null, + lat: num(draft.lat) ?? null, + lng: num(draft.lng) ?? null, + google_place_id: str(draft.google_place_id) ?? null, + google_ftid: str(draft.google_ftid) ?? null, + osm_id: str(draft.osm_id) ?? null, + website: str(draft.website) ?? null, + phone: str(draft.phone) ?? null, + 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() } + setDraft(null) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { - setAddingIdx(null) + setSaving(false) } } - return ( - -
-
-
- - setQuery(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') 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" - /> -
- -
+ const title = draft ? (str(draft.name) ?? t('collections.addPlace')) : t('collections.addPlace') -
- {results.length === 0 && !searching && ( -

{t('collections.addPlaceHint')}

+ return ( + + {!draft ? ( +
+
+
+ + setQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') 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 && !searching && ( +

{t('collections.addPlaceHint')}

+ )} + {results.map((r, i) => ( + + ))} +
+
+ ) : ( +
+ + {str(draft.address) && ( +
{str(draft.address)}
)} - {results.map((r, i) => ( -
-
-
- {str(r.name)} - {str(r.address) && {str(r.address)}} -
- + ) + })} +
+ + {/* Description */} +
+ + +