From 0c484959ce6079e4ee5de64324463f8f6676e09f Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Jul 2026 13:37:25 +0200 Subject: [PATCH] feat(collections): filters, add-place popup, category badges, map-click hardening (#1081) - Map: markers no longer rebuild on every unrelated re-render (memoised the mappable list + only update the hero size when it really changes), the floating controls bar is click-through except its buttons, and the collection map runs with the hover tooltip off. Together these stop a marker click from landing on a mid-rebuild element / the tooltip so the pick actually registers. - Filters moved out of the hero into a compact status + category dropdown row above the places (custom dropdowns); the hero no longer carries the stat chips. - Add-place is a single popup now: search fills the location, and name / status / category / description / links are all editable together before saving. - Category shown as a badge top-left on the detail cover and next to the status in each list row (divided by a hairline). - Slimmer hero: shorter, tighter spacing, links tucked into the eyebrow row instead of their own line. --- .../Collections/AddPlaceToCollectionModal.tsx | 220 +++++++++--------- .../Collections/CollectionFilterBar.tsx | 108 +++++++++ .../components/Collections/CollectionHero.tsx | 52 ++--- .../components/Collections/CollectionList.tsx | 22 +- .../components/Collections/CollectionMap.tsx | 1 + .../Collections/CollectionPlaceDetail.tsx | 14 +- client/src/components/Map/MapView.tsx | 6 +- client/src/components/Map/MapViewGL.tsx | 9 +- client/src/hooks/useElementSize.ts | 6 +- client/src/pages/CollectionsPage.tsx | 35 ++- .../src/pages/collections/collectionsModel.ts | 16 ++ .../src/pages/collections/useCollections.ts | 21 +- client/src/store/collectionStore.ts | 4 + client/src/styles/collections.css | 48 +++- 14 files changed, 371 insertions(+), 191 deletions(-) create mode 100644 client/src/components/Collections/CollectionFilterBar.tsx diff --git a/client/src/components/Collections/AddPlaceToCollectionModal.tsx b/client/src/components/Collections/AddPlaceToCollectionModal.tsx index a9a632011..392ca15fa 100644 --- a/client/src/components/Collections/AddPlaceToCollectionModal.tsx +++ b/client/src/components/Collections/AddPlaceToCollectionModal.tsx @@ -2,15 +2,15 @@ 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 { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react' import Modal from '../shared/Modal' 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 { getCategoryIcon } from '../shared/categoryIcons' import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' import type { Category, TranslationFn } from '../../types' import type { CollectionLink, CollectionStatus } from '@trek/shared' @@ -30,10 +30,10 @@ interface AddPlaceToCollectionModalProps { } /** - * 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. + * 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() @@ -41,8 +41,9 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) - // Step 2 draft — the picked place plus the editable detail fields. - const [draft, setDraft] = useState(null) + // The picked location (address/coords/ids) plus the editable fields. + const [picked, setPicked] = useState(null) + const [name, setName] = useState('') const [categoryId, setCategoryId] = useState(null) const [description, setDescription] = useState('') const [links, setLinks] = useState([]) @@ -50,9 +51,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec const [saving, setSaving] = useState(false) const descRef = useRef(null) - useEffect(() => { - if (!isOpen) { setQuery(''); setResults([]); setDraft(null) } - }, [isOpen]) + const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') } + useEffect(() => { if (!isOpen) reset() }, [isOpen]) const search = async () => { if (!query.trim()) return @@ -67,27 +67,26 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec } } - const pick = (r: MapsPlace) => { setDraft(r); setDescription(''); setLinks([]); setStatus('idea'); setCategoryId(null) } + const pick = (r: MapsPlace) => { setPicked(r); setName(str(r.name) ?? ''); setResults([]); setQuery(str(r.name) ?? query) } 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 + 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) setSaving(true) try { const res = await collectionsApi.savePlace({ collection_id: collectionId, - name, - 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, + name: cleanName, + address: (picked && str(picked.address)) ?? null, + lat: (picked && num(picked.lat)) ?? null, + lng: (picked && num(picked.lng)) ?? 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, @@ -96,7 +95,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec }) if (res.duplicate) toast.info(t('collections.duplicateWarning')) else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() } - setDraft(null) + reset() } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { @@ -104,12 +103,13 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec } } - const title = draft ? (str(draft.name) ?? t('collections.addPlace')) : t('collections.addPlace') + const address = picked ? str(picked.address) : undefined return ( - - {!draft ? ( -
+ +
+ {/* Search — picking a result fills the location below */} +
@@ -117,7 +117,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec autoFocus value={query} onChange={e => setQuery(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') search() }} + 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" /> @@ -127,32 +127,34 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec {t('common.search')}
-
- {results.length === 0 && !searching && ( -

{t('collections.addPlaceHint')}

- )} - {results.map((r, i) => ( - - ))} -
-
- ) : ( -
- - {str(draft.address) && ( -
{str(draft.address)}
+ {results.length > 0 && ( +
+
+ {t('common.search')} + +
+ {results.map((r, i) => ( + + ))} +
)} +
- {/* Status */} + {/* 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 &&
{address}
} +
+ + {/* Status */} +
{STATUS_ORDER.map(s => { const Icon = STATUS_META[s].icon @@ -164,70 +166,70 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec ) })}
+
- {/* 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 */} + {/* Category */} + {categories.length > 0 && (
- - -