diff --git a/client/src/components/Collections/AddPlaceToCollectionModal.tsx b/client/src/components/Collections/AddPlaceToCollectionModal.tsx index 6c7cd353f..a9a632011 100644 --- a/client/src/components/Collections/AddPlaceToCollectionModal.tsx +++ b/client/src/components/Collections/AddPlaceToCollectionModal.tsx @@ -10,8 +10,9 @@ import { collectionsApi } from '../../api/collections' 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 { TranslationFn } from '../../types' +import type { Category, TranslationFn } from '../../types' import type { CollectionLink, CollectionStatus } from '@trek/shared' type MapsPlace = Record @@ -22,6 +23,7 @@ interface AddPlaceToCollectionModalProps { isOpen: boolean collectionId: number collectionName: string + categories: Category[] onClose: () => void onAdded: () => void t: TranslationFn @@ -33,7 +35,7 @@ interface AddPlaceToCollectionModalProps { * 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 { +export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement { const { language } = useTranslation() const toast = useToast() const [query, setQuery] = useState('') @@ -41,6 +43,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec const [searching, setSearching] = useState(false) // Step 2 draft — the picked place plus the editable detail fields. const [draft, setDraft] = useState(null) + const [categoryId, setCategoryId] = useState(null) const [description, setDescription] = useState('') const [links, setLinks] = useState([]) const [status, setStatus] = useState('idea') @@ -64,7 +67,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec } } - const pick = (r: MapsPlace) => { setDraft(r); setDescription(''); setLinks([]); setStatus('idea') } + const pick = (r: MapsPlace) => { setDraft(r); setDescription(''); setLinks([]); setStatus('idea'); setCategoryId(null) } const setLink = (i: number, patch: Partial) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) const save = async () => { @@ -85,6 +88,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec osm_id: str(draft.osm_id) ?? null, website: str(draft.website) ?? null, phone: str(draft.phone) ?? null, + category_id: categoryId, description: description.trim() || null, links: cleanLinks, status, @@ -161,6 +165,34 @@ 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 */}
diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index b873e1634..9e8a984e2 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -2,13 +2,13 @@ import React, { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' -import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check } from 'lucide-react' +import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react' import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared' -import type { TranslationFn } from '../../types' +import type { Category, TranslationFn } from '../../types' import MarkdownToolbar from '../Journey/MarkdownToolbar' -import StatusBadge from './StatusBadge' import { entityGradient } from '../../utils/gradients' -import { normalizeLinkUrl } from '../../pages/collections/collectionsModel' +import { getCategoryIcon } from '../shared/categoryIcons' +import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel' import { useToast } from '../shared/Toast' import { getApiErrorMessage } from '../../types' @@ -19,55 +19,71 @@ function linkHost(url: string): string { interface CollectionPlaceDetailProps { place: CollectionPlace canEdit: boolean + categories: Category[] /** When set, dock the sheet over that column (desktop split) instead of centred. */ anchorRect?: { left: number; width: number } | null onClose: () => void onSetStatus: (status: CollectionStatus) => void - onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[] }) => Promise + onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise onCopyToTrip: () => void onRemove: () => void t: TranslationFn } +function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: (s: CollectionStatus) => void; t: TranslationFn }): React.ReactElement { + return ( +
+ {STATUS_ORDER.map(s => { + const Icon = STATUS_META[s].icon + const on = status === s + return ( + + ) + })} +
+ ) +} + /** - * Bottom detail sheet for a saved place — a glassy card docked to the bottom - * (full-width on mobile). Read mode renders the description as markdown + link - * chips; edit mode swaps in a name field, a markdown editor (toolbar + textarea) - * and a links editor, saving via collectionsApi.updatePlace. Also cycles the - * status, copies the place to a trip and removes it from the list. + * Bottom detail sheet for a saved place — an opaque, clearly-sectioned card + * (cover → meta → status → description → links) docked over the list column. + * Read mode renders the description as markdown + link chips; edit mode swaps in + * name / category / markdown description / links, saving via updatePlace. Status + * is an always-live segmented control (auto-saves). */ export default function CollectionPlaceDetail({ - place, canEdit, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t, + place, canEdit, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t, }: CollectionPlaceDetailProps): React.ReactElement { const toast = useToast() const [editing, setEditing] = useState(false) const [name, setName] = useState(place.name) + const [categoryId, setCategoryId] = useState(place.category_id ?? null) const [description, setDescription] = useState(place.description ?? '') const [links, setLinks] = useState(place.links ?? []) const [saving, setSaving] = useState(false) const descRef = useRef(null) - // Reset the form only when a DIFFERENT place is opened (keyed on id, not on - // every field change — otherwise a save would clobber in-flight edits). + // Reset only when a DIFFERENT place is opened (keyed on id, not on every field). useEffect(() => { setEditing(false) setName(place.name) + setCategoryId(place.category_id ?? null) setDescription(place.description ?? '') setLinks(place.links ?? []) // eslint-disable-next-line react-hooks/exhaustive-deps }, [place.id]) - const sub = place.category?.name || place.address const banner = place.image_url - - const setLink = (i: number, patch: Partial) => - setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) + const setLink = (i: number, patch: Partial) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) + const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) } const save = async () => { const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url) setSaving(true) try { - await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks }) + await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId }) setEditing(false) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) @@ -77,6 +93,7 @@ export default function CollectionPlaceDetail({ } const dockStyle = anchorRect ? { left: anchorRect.left, width: anchorRect.width, transform: 'none' as const } : undefined + const CatIcon = getCategoryIcon(place.category?.icon) return (
e.stopPropagation()}> @@ -85,56 +102,65 @@ export default function CollectionPlaceDetail({
- {editing ? ( - setName(e.target.value)} className="col-detail-name-input" autoFocus /> - ) : ( -

{place.name}

- )} - {sub &&
{!place.category?.name && }{sub}
} + {editing + ? setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} /> + :

{place.name}

}
-
- -
- {canEdit && !editing && ( - - )} - - {canEdit && ( - - )} -
+ {/* Meta (view only) */} + {!editing && (place.category?.name || place.address) && ( +
+ {place.category?.name && ( + + {place.category.name} + + )} + {place.address && {place.address}} +
+ )} + + {/* Status — always live */} + {editing ? (
- - -