mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
feat(collections): detail redesign + categories, close-on-map, highlight fix (#1081)
- Rebuild the place detail as a clean, opaque, sectioned sheet (cover → meta → status segment → description → links) with a proper footer action bar — the loose "white lower half" is gone. - Assign a place to a central (admin-defined) category, both in the detail edit and when adding a place; categories are fetched once for the page. - Add-place now sets category + description (markdown) + links + status in the same step, closer to the trip's place form. - Switching to the full-map view now closes the (list-docked) detail. - Fix the selected-row highlight: it was clipped by the column's overflow — use an inset ring and only clip during the map-collapse animation; a picked row now scrolls into view above the detail sheet. - New category strings across all 22 locales.
This commit is contained in:
@@ -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<string, unknown>
|
||||
@@ -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<MapsPlace | null>(null)
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
const [status, setStatus] = useState<CollectionStatus>('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<CollectionLink>) => 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
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.category')}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button type="button" onClick={() => setCategoryId(null)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors ${categoryId == null ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
|
||||
{t('collections.noCategory')}
|
||||
</button>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon ?? undefined)
|
||||
const on = categoryId === cat.id
|
||||
const col = cat.color || '#6366f1'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setCategoryId(cat.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors bg-surface-card border-edge hover:bg-surface-hover"
|
||||
style={on ? { color: col, background: `color-mix(in oklch, ${col} 15%, transparent)`, borderColor: `color-mix(in oklch, ${col} 40%, transparent)` } : undefined}
|
||||
>
|
||||
<Icon size={13} /> {cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
|
||||
|
||||
@@ -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<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: (s: CollectionStatus) => void; t: TranslationFn }): React.ReactElement {
|
||||
return (
|
||||
<div className="col-detail-seg" role="group">
|
||||
{STATUS_ORDER.map(s => {
|
||||
const Icon = STATUS_META[s].icon
|
||||
const on = status === s
|
||||
return (
|
||||
<button key={s} type="button" aria-pressed={on} onClick={() => onSet(s)} className={on ? 'on' : ''}>
|
||||
<Icon size={14} style={{ color: on ? STATUS_META[s].color : undefined }} /> {t(STATUS_META[s].labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const descRef = useRef<HTMLTextAreaElement>(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<CollectionLink>) =>
|
||||
setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => 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 (
|
||||
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} onClick={e => e.stopPropagation()}>
|
||||
@@ -85,56 +102,65 @@ export default function CollectionPlaceDetail({
|
||||
<div className="col-detail-cover-scrim" />
|
||||
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
|
||||
<div className="col-detail-head">
|
||||
{editing ? (
|
||||
<input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus />
|
||||
) : (
|
||||
<h2 className="col-detail-name">{place.name}</h2>
|
||||
)}
|
||||
{sub && <div className="col-detail-sub">{!place.category?.name && <MapPin size={12} />}{sub}</div>}
|
||||
{editing
|
||||
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
|
||||
: <h2 className="col-detail-name">{place.name}</h2>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-detail-body">
|
||||
<div className="col-detail-actions">
|
||||
<StatusBadge status={place.status} onChange={onSetStatus} t={t} />
|
||||
<div className="col-detail-actions-spacer" />
|
||||
{canEdit && !editing && (
|
||||
<button type="button" className="col-detail-btn" onClick={() => setEditing(true)}><Pencil size={14} /> {t('common.edit')}</button>
|
||||
)}
|
||||
<button type="button" className="col-detail-btn" onClick={onCopyToTrip}><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
||||
{canEdit && (
|
||||
<button type="button" className="col-detail-btn danger" onClick={onRemove}><Trash2 size={14} /> {t('collections.removeFromList')}</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Meta (view only) */}
|
||||
{!editing && (place.category?.name || place.address) && (
|
||||
<div className="col-detail-meta">
|
||||
{place.category?.name && (
|
||||
<span className="col-detail-catchip" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
|
||||
<CatIcon size={12} /> {place.category.name}
|
||||
</span>
|
||||
)}
|
||||
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status — always live */}
|
||||
<StatusSegment status={place.status} onSet={onSetStatus} t={t} />
|
||||
|
||||
{editing ? (
|
||||
<div className="col-detail-edit">
|
||||
<label className="col-detail-label">{t('collections.description')}</label>
|
||||
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
|
||||
<textarea
|
||||
ref={descRef}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={t('collections.descriptionPlaceholder')}
|
||||
className="col-detail-textarea"
|
||||
/>
|
||||
|
||||
<label className="col-detail-label">{t('collections.links')}</label>
|
||||
<div className="col-detail-links-edit">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="col-detail-link-row">
|
||||
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="col-detail-input w-28" />
|
||||
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="col-detail-input flex-1" />
|
||||
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="col-detail-icon-btn" aria-label={t('common.delete')}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="col-detail-add-link"><Plus size={13} /> <Link2 size={12} /> {t('collections.addLink')}</button>
|
||||
{/* Category */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><Tag size={12} /> {t('collections.category')}</div>
|
||||
<div className="col-detail-cats">
|
||||
<button type="button" onClick={() => setCategoryId(null)} className={`col-detail-cat${categoryId == null ? ' on' : ''}`}>{t('collections.noCategory')}</button>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon ?? undefined)
|
||||
const on = categoryId === cat.id
|
||||
return (
|
||||
<button key={cat.id} type="button" onClick={() => setCategoryId(cat.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: cat.color || '#6366f1' }}>
|
||||
<Icon size={12} /> {cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-detail-edit-actions">
|
||||
<button type="button" onClick={() => { setEditing(false); setName(place.name); setDescription(place.description ?? ''); setLinks(place.links ?? []) }} className="col-detail-btn">{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={saving} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
|
||||
{/* Description */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.description')}</div>
|
||||
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
|
||||
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={4} placeholder={t('collections.descriptionPlaceholder')} className="col-detail-textarea" />
|
||||
</div>
|
||||
{/* Links */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.links')}</div>
|
||||
<div className="col-detail-links-edit">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="col-detail-link-row">
|
||||
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="col-detail-input w-28" />
|
||||
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="col-detail-input flex-1" />
|
||||
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="col-detail-icon-btn" aria-label={t('common.delete')}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="col-detail-add-link"><Plus size={13} /> <Link2 size={12} /> {t('collections.addLink')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -153,11 +179,22 @@ export default function CollectionPlaceDetail({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!place.description && (!place.links || place.links.length === 0) && canEdit && (
|
||||
<button type="button" className="col-detail-empty" onClick={() => setEditing(true)}>
|
||||
<Plus size={14} /> {t('collections.addDetails')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-detail-footer">
|
||||
{editing ? (
|
||||
<>
|
||||
<button type="button" onClick={resetForm} className="col-detail-btn">{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={saving} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
|
||||
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
||||
<div className="col-detail-footer-spacer" />
|
||||
{canEdit && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user