From 0910c90e7f52d7736bf76adc16adf2980942325d Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Jul 2026 12:00:37 +0200 Subject: [PATCH] fix(collections): detail-sheet, edit-refresh, map + rail polish (#1081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Editing a place (status, description, title, …) no longer reloads the view or closes the detail: the WS echo now refreshes via loadCollection, which keeps the current selection + select-mode instead of setActive resetting them. - Place detail: docks over the list column on the desktop split (measured rect) instead of centred over the map, and the card is now opaque (was too see-through). - Map: click a marker in full-map view to drop back to the split; picking a place scrolls its list row into view; the select toggle is disabled in full-map view; the floating controls are one non-overlapping top bar and less transparent. - Hero: drop the New-list button (it's already in the rail). - Rail: the kebab is always visible (easy to hit); menu is Edit + Delete only (colour moved into the editor); "Rename" → "Edit". - Add-place: pick a result, then set description (markdown) / links / status before saving, all in one step. - Share modal: member roster as cards with clearer role badges + a count. --- .../Collections/AddPlaceToCollectionModal.tsx | 186 +++++++++++++----- .../components/Collections/CollectionHero.tsx | 8 +- .../components/Collections/CollectionList.tsx | 9 +- .../Collections/CollectionMapPanel.tsx | 1 + .../Collections/CollectionPlaceDetail.tsx | 8 +- .../src/components/Collections/ListsRail.tsx | 81 +++----- .../Collections/ShareCollectionModal.tsx | 25 +-- client/src/hooks/useElementRect.ts | 40 ++++ client/src/pages/CollectionsPage.tsx | 15 +- .../src/pages/collections/useCollections.ts | 25 ++- client/src/styles/collections.css | 37 ++-- 11 files changed, 267 insertions(+), 168 deletions(-) create mode 100644 client/src/hooks/useElementRect.ts 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 */} +
+ + +