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:
Maurice
2026-07-01 12:34:35 +02:00
committed by Maurice
parent 0910c90e7f
commit f97e284592
27 changed files with 245 additions and 97 deletions
@@ -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>
+9 -3
View File
@@ -48,7 +48,11 @@ export default function CollectionsPage(): React.ReactElement {
const mappable = mappablePlaces(c.visiblePlaces)
const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id)
const deselect = () => c.setSelectedPlaceId(null)
const toggleView = () => c.setView(c.view === 'map' ? 'list' : 'map')
const toggleView = () => {
// Going to the full-map view closes the (list-docked) detail sheet.
if (c.view !== 'map') c.setSelectedPlaceId(null)
c.setView(c.view === 'map' ? 'list' : 'map')
}
// Clicking a marker in the full-map view drops back to the split so the list
// + detail come into view alongside the map.
const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') }
@@ -247,11 +251,12 @@ export default function CollectionsPage(): React.ReactElement {
visible + interactive). On the desktop split it docks over the list
column so the map stays clear; otherwise it's a full-width sheet.
Clicking another place / the map background re-points the selection. */}
{c.selectedPlace && (
{c.selectedPlace && c.view !== 'map' && (
<CollectionPlaceDetail
place={c.selectedPlace}
canEdit
anchorRect={desktopSplit && c.view === 'list' ? c.listColRect : null}
categories={c.categories}
anchorRect={desktopSplit ? c.listColRect : null}
onClose={c.handleCloseDetail}
onSetStatus={c.handleDetailStatus}
onSave={patch => c.updatePlace(c.selectedPlace!.id, patch)}
@@ -268,6 +273,7 @@ export default function CollectionsPage(): React.ReactElement {
isOpen={c.showAddPlace}
collectionId={c.activeId}
collectionName={c.activeCollection.name}
categories={c.categories}
onClose={() => c.setShowAddPlace(false)}
onAdded={c.handlePlaceAdded}
t={t}
@@ -9,6 +9,7 @@ import { getApiErrorMessage } from '../../types'
import { addListener, removeListener } from '../../api/websocket'
import { useCollectionStore, ALL_SAVED } from '../../store/collectionStore'
import type { ActiveCollectionId } from '../../store/collectionStore'
import { categoriesApi } from '../../api/client'
import type { Collection, CollectionStatus } from '@trek/shared'
import type { Category, Place } from '../../types'
import { filterPlaces, sortPlaces, statusCounts } from './collectionsModel'
@@ -70,6 +71,10 @@ export function useCollections() {
// Initial load.
useEffect(() => { loadAll() }, [])
// Central admin-defined categories, for assigning a category to a saved place.
const [categories, setCategories] = useState<Category[]>([])
useEffect(() => { categoriesApi.list().then((d: { categories: Category[] }) => setCategories(d.categories ?? [])).catch(() => {}) }, [])
// When the list↔map split toggles, its grid columns animate; keep nudging the
// mounted map to re-layout during the transition (Leaflet's trackResize hooks
// window resize; MapLibre observes its own box). Skip the initial mount — the
@@ -277,7 +282,7 @@ export function useCollections() {
return {
t, language, dark, navigate,
isWide, heroRef: hero.ref, heroHeight: hero.height,
listColRef: listCol.ref, listColRect: listCol.rect,
listColRef: listCol.ref, listColRect: listCol.rect, categories,
// store data
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
canShare, shareMemberCount,
+49 -25
View File
@@ -251,8 +251,11 @@
background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-xs);
cursor: pointer; transition: transform .12s, box-shadow .15s, border-color .15s;
}
/* Keep a picked row above the docked detail sheet (which covers the lower ~62vh). */
.trek-dash .col-lrow { scroll-margin: calc(var(--nav-h) + 90px) 0 62vh; }
.trek-dash .col-lrow:hover { transform: translateX(3px); box-shadow: var(--sh-sm); }
.trek-dash .col-lrow.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
/* Inset ring so the highlight is never clipped by the column's overflow. */
.trek-dash .col-lrow.sel { border-color: var(--accent); box-shadow: inset 0 0 0 1.5px var(--accent); }
.trek-dash .col-lrow .li { min-width: 0; }
.trek-dash .col-lrow .li .t { font-size: 14px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-lrow .li .s { font-size: 12px; color: var(--ink-3); margin-top: 2px; display: flex; align-items: center; gap: 5px; overflow: hidden; }
@@ -276,8 +279,9 @@
transition: grid-template-columns .4s cubic-bezier(.2,.7,.2,1), gap .4s cubic-bezier(.2,.7,.2,1);
}
.trek-dash .col-split.map-full { grid-template-columns: 0fr 1fr; gap: 0; }
.trek-dash .col-split-list { min-width: 0; overflow: hidden; transition: opacity .25s; }
.trek-dash .col-split.map-full .col-split-list { opacity: 0; pointer-events: none; }
.trek-dash .col-split-list { min-width: 0; overflow: visible; transition: opacity .25s; }
/* Clip only while the column collapses under the full-map view. */
.trek-dash .col-split.map-full .col-split-list { opacity: 0; pointer-events: none; overflow: hidden; }
.trek-dash .col-split-map {
position: sticky; top: calc(var(--nav-h) + 22px);
height: calc(100vh - var(--nav-h) - 210px); min-height: 440px;
@@ -316,37 +320,57 @@
backdrop-filter: blur(8px); transition: background .12s;
}
.trek-dash .col-detail-close:hover { background: rgba(0,0,0,.6); }
.trek-dash .col-detail-head { position: absolute; left: 18px; right: 18px; bottom: 12px; z-index: 1; color: #fff; }
.trek-dash .col-detail-name { font-size: 21px; font-weight: 700; letter-spacing: -.02em; margin: 0; text-shadow: 0 1px 6px rgba(0,0,0,.5); }
.trek-dash .col-detail-head { position: absolute; left: 20px; right: 20px; bottom: 12px; z-index: 1; color: #fff; }
.trek-dash .col-detail-name { font-size: 22px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; margin: 0; text-shadow: 0 1px 6px rgba(0,0,0,.55); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.trek-dash .col-detail-name-input { width: 100%; font-size: 20px; font-weight: 700; background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.32); border-radius: 8px; padding: 3px 8px; color: #fff; outline: none; }
.trek-dash .col-detail-sub { display: flex; align-items: center; gap: 5px; font-size: 12.5px; margin-top: 3px; opacity: .92; text-shadow: 0 1px 4px rgba(0,0,0,.5); }
.trek-dash .col-detail-body { padding: 16px 18px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
.trek-dash .col-detail-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.trek-dash .col-detail-actions-spacer { flex: 1 1 auto; }
.trek-dash .col-detail-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line-2); transition: background .12s, color .12s; }
.trek-dash .col-detail-btn:hover { background: var(--surface); color: var(--ink); }
.trek-dash .col-detail-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.trek-dash .col-detail-btn.danger { color: oklch(0.6 0.2 25); }
.trek-dash .col-detail-btn.danger:hover { background: oklch(0.6 0.2 25 / .12); }
.trek-dash .col-detail-md { font-size: 13.5px; color: var(--ink-2); line-height: 1.5; }
.trek-dash .col-detail-body { padding: 16px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
/* meta: category chip + address */
.trek-dash .col-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.trek-dash .col-detail-catchip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 15%, transparent); }
.trek-dash .col-detail-addr { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink-3); min-width: 0; }
.trek-dash .col-detail-addr svg { flex-shrink: 0; }
/* status segmented control */
.trek-dash .col-detail-seg { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 4px; border-radius: 12px; background: var(--surface-2); }
.trek-dash .col-detail-seg button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 6px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); transition: background .12s, color .12s, box-shadow .12s; }
.trek-dash .col-detail-seg button:hover { color: var(--ink); }
.trek-dash .col-detail-seg button.on { background: var(--surface); color: var(--ink); box-shadow: var(--sh-xs); }
/* rendered markdown + link chips (view) */
.trek-dash .col-detail-md { font-size: 13.5px; color: var(--ink-2); line-height: 1.55; }
.trek-dash .col-detail-links { display: flex; flex-wrap: wrap; gap: 7px; }
.trek-dash .col-detail-link { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 500; color: var(--accent-ink); background: var(--accent-soft); transition: filter .12s; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-detail-link { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 500; color: var(--accent-ink); background: var(--accent-soft); transition: filter .12s; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-detail-link:hover { filter: brightness(1.05); }
.trek-dash .col-detail-empty { display: inline-flex; align-items: center; gap: 7px; align-self: flex-start; padding: 8px 12px; border-radius: 9px; border: 1px dashed var(--line-2); color: var(--ink-3); font-size: 12.5px; font-weight: 500; }
.trek-dash .col-detail-empty:hover { color: var(--ink); border-color: var(--ink-3); }
.trek-dash .col-detail-edit { display: flex; flex-direction: column; gap: 8px; }
.trek-dash .col-detail-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--ink-3); margin-top: 4px; }
.trek-dash .col-detail-textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 13.5px; font-family: inherit; outline: none; resize: vertical; }
.trek-dash .col-detail-textarea:focus { border-color: var(--accent); }
/* edit form */
.trek-dash .col-detail-edit { display: flex; flex-direction: column; gap: 16px; }
.trek-dash .col-detail-field { display: flex; flex-direction: column; gap: 8px; }
.trek-dash .col-detail-label { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--ink-3); }
.trek-dash .col-detail-cats { display: flex; flex-wrap: wrap; gap: 7px; }
.trek-dash .col-detail-cat { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-detail-cat:hover { background: var(--surface); border-color: var(--line-2); }
.trek-dash .col-detail-cat.on { color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--cat, var(--accent)) 40%, transparent); }
.trek-dash .col-detail-textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--surface-2); color: var(--ink); font-size: 13.5px; font-family: inherit; outline: none; resize: vertical; }
.trek-dash .col-detail-textarea:focus { border-color: var(--accent); background: var(--surface); }
.trek-dash .col-detail-links-edit { display: flex; flex-direction: column; gap: 8px; }
.trek-dash .col-detail-link-row { display: flex; align-items: center; gap: 8px; }
.trek-dash .col-detail-input { padding: 7px 10px; border-radius: 9px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 12.5px; outline: none; min-width: 0; }
.trek-dash .col-detail-input:focus { border-color: var(--accent); }
.trek-dash .col-detail-input { padding: 7px 10px; border-radius: 9px; border: 1px solid var(--line-2); background: var(--surface-2); color: var(--ink); font-size: 12.5px; outline: none; min-width: 0; }
.trek-dash .col-detail-input:focus { border-color: var(--accent); background: var(--surface); }
.trek-dash .col-detail-icon-btn { padding: 7px; border-radius: 8px; color: var(--ink-3); }
.trek-dash .col-detail-icon-btn:hover { color: oklch(0.6 0.2 25); background: var(--surface-2); }
.trek-dash .col-detail-add-link { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 11px; border-radius: 9px; border: 1px dashed var(--line-2); color: var(--ink-2); font-size: 12.5px; font-weight: 500; }
.trek-dash .col-detail-add-link:hover { background: var(--surface-2); }
.trek-dash .col-detail-edit-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
/* footer action bar */
.trek-dash .col-detail-footer { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); flex-shrink: 0; }
.trek-dash .col-detail-footer-spacer { flex: 1 1 auto; }
.trek-dash .col-detail-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line-2); transition: background .12s, color .12s; }
.trek-dash .col-detail-btn:hover { background: var(--surface); color: var(--ink); }
.trek-dash .col-detail-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.trek-dash .col-detail-btn.danger { color: oklch(0.6 0.2 25); border-color: transparent; background: transparent; }
.trek-dash .col-detail-btn.danger:hover { background: oklch(0.6 0.2 25 / .12); }
@media (max-width: 720px) {
.trek-dash .col-detail { left: 0; right: 0; bottom: 0; transform: none; width: 100%; max-height: 84vh; border-radius: var(--r-xl) var(--r-xl) 0 0; }
}