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.
This commit is contained in:
Maurice
2026-07-01 13:37:25 +02:00
committed by Maurice
parent f97e284592
commit 0c484959ce
14 changed files with 371 additions and 191 deletions
@@ -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<MapsPlace[]>([])
const [searching, setSearching] = useState(false)
// Step 2 draft — the picked place plus the editable detail fields.
const [draft, setDraft] = useState<MapsPlace | null>(null)
// The picked location (address/coords/ids) plus the editable fields.
const [picked, setPicked] = useState<MapsPlace | null>(null)
const [name, setName] = useState('')
const [categoryId, setCategoryId] = useState<number | null>(null)
const [description, setDescription] = useState('')
const [links, setLinks] = useState<CollectionLink[]>([])
@@ -50,9 +51,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const [saving, setSaving] = useState(false)
const descRef = useRef<HTMLTextAreaElement>(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<CollectionLink>) => 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 (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="md">
{!draft ? (
<div className="flex flex-col gap-3">
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.addPlace')} size="md">
<div className="flex flex-col gap-4">
{/* Search — picking a result fills the location below */}
<div className="relative">
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
@@ -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')}
</button>
</div>
<div className="flex flex-col gap-1.5 max-h-[380px] overflow-y-auto">
{results.length === 0 && !searching && (
<p className="text-[13px] text-content-faint py-6 text-center">{t('collections.addPlaceHint')}</p>
)}
{results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-edge bg-surface-card text-left hover:border-accent transition-colors">
<div className="w-9 h-9 rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={16} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13.5px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[12px] text-content-faint truncate">{str(r.address)}</span>}
</div>
<ArrowLeft size={15} className="text-content-faint rotate-180 shrink-0" />
</button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<button type="button" onClick={() => setDraft(null)} className="inline-flex items-center gap-1.5 self-start text-[12.5px] font-medium text-content-secondary hover:text-content">
<ArrowLeft size={14} /> {t('common.search')}
</button>
{str(draft.address) && (
<div className="flex items-center gap-1.5 text-[12.5px] text-content-faint"><MapPin size={13} /> {str(draft.address)}</div>
{results.length > 0 && (
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
<button type="button" onClick={() => setResults([])} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
</div>
{results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
<div className="w-8 h-8 rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
</div>
</button>
))}
</div>
)}
</div>
{/* Status */}
{/* Name */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('common.name')}</label>
<input value={name} onChange={e => 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 && <div className="flex items-center gap-1.5 mt-1.5 text-[12px] text-content-faint"><MapPin size={12} /> {address}</div>}
</div>
{/* Status */}
<div>
<div className="flex gap-1.5">
{STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
@@ -164,70 +166,70 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
)
})}
</div>
</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 */}
{/* Category */}
{categories.length > 0 && (
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={3} placeholder={t('collections.descriptionPlaceholder')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y" />
{description.trim() && (
<div className="collab-note-md mt-2 text-[13px] text-content-secondary"><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{description}</Markdown></div>
)}
</div>
{/* Links */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="w-28 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}><Trash2 size={14} /></button>
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
<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>
)}
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={() => setDraft(null)} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={saving} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('common.add')}
{/* Description */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={3} placeholder={t('collections.descriptionPlaceholder')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y" />
{description.trim() && (
<div className="collab-note-md mt-2 text-[13px] text-content-secondary"><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{description}</Markdown></div>
)}
</div>
{/* Links */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="w-28 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}><Trash2 size={14} /></button>
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
</button>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={saving || !name.trim()} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('common.add')}
</button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,108 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag } from 'lucide-react'
import type { StatusFilter } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CategoryOption } from '../../pages/collections/collectionsModel'
interface Opt {
key: string | number
label: string
icon?: React.ReactNode
count?: number
}
/** Small custom dropdown — compact trigger + click-away popover. */
function Dropdown({ current, options, onSelect, lead }: {
current: string | number
options: Opt[]
onSelect: (key: string | number) => void
lead: React.ReactNode
}): React.ReactElement {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="col-filter" ref={ref}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
{cur.icon ?? lead}
<span className="col-filter-lbl">{cur.label}</span>
<ChevronDown size={14} className="col-filter-chev" />
</button>
{open && (
<div className="col-filter-pop" role="listbox">
{options.map(o => (
<button
key={o.key}
type="button"
role="option"
aria-selected={o.key === current}
className={`col-filter-opt${o.key === current ? ' on' : ''}`}
onClick={() => { onSelect(o.key); setOpen(false) }}
>
{o.icon ?? <span className="col-filter-dot ghost" />}
<span className="col-filter-lbl">{o.label}</span>
{o.count != null && <span className="col-filter-count">{o.count}</span>}
{o.key === current && <Check size={13} className="col-filter-check" />}
</button>
))}
</div>
)}
</div>
)
}
interface CollectionFilterBarProps {
statusFilter: StatusFilter
counts: Record<StatusFilter, number>
categoryFilter: number | 'all'
categoryOptions: CategoryOption[]
onStatusFilter: (f: StatusFilter) => void
onCategoryFilter: (f: number | 'all') => void
t: TranslationFn
}
/**
* Filter row above the places — a status dropdown (All / Idea / Want / Visited
* with counts) and, when the list has categorised places, a category dropdown.
* Custom compact dropdowns so they barely take any space.
*/
export default function CollectionFilterBar({
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter, t,
}: CollectionFilterBarProps): React.ReactElement {
const statusOpts: Opt[] = [
{ key: 'all', label: t('common.all'), count: counts.all },
...STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
return { key: s, label: t(STATUS_META[s].labelKey), icon: <Icon size={13} style={{ color: STATUS_META[s].color }} />, count: counts[s] }
}),
]
const catTotal = categoryOptions.reduce((n, c) => n + c.count, 0)
const catOpts: Opt[] = [
{ key: 'all', label: t('common.all'), count: catTotal },
...categoryOptions.map(c => {
const Icon = getCategoryIcon(c.icon ?? undefined)
return { key: c.id, label: c.name, icon: <Icon size={13} style={{ color: c.color ?? undefined }} />, count: c.count }
}),
]
return (
<div className="col-filterbar">
<Dropdown current={statusFilter} options={statusOpts} onSelect={k => onStatusFilter(k as StatusFilter)} lead={<Layers size={13} />} />
{categoryOptions.length > 0 && (
<Dropdown current={categoryFilter} options={catOpts} onSelect={k => onCategoryFilter(k as number | 'all')} lead={<Tag size={13} />} />
)}
</div>
)
}
@@ -2,8 +2,6 @@ import React from 'react'
import { Share2, Users, Link2 } from 'lucide-react'
import type { CollectionMember, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
import type { StatusFilter } from '../../store/collectionStore'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
const AV_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#3b82f6', '#ef4444', '#22c55e']
@@ -19,9 +17,6 @@ interface CollectionHeroProps {
coverImage?: string | null
description?: string | null
links?: CollectionLink[]
counts: Record<StatusFilter, number>
statusFilter: StatusFilter
onStatusFilter: (f: StatusFilter) => void
/** Accepted members (owner first) — shown as an avatar stack when shared. */
members: CollectionMember[]
canShare: boolean
@@ -34,16 +29,16 @@ interface CollectionHeroProps {
/**
* The page header — a colour-washed (or cover-image) glass hero that gives the
* active list an identity: an eyebrow with the sharing state + member avatars,
* the big list name, and a row of stat chips that double as the status filter
* (All / Idea / Want / Visited with live counts). Share + New-list actions sit
* top-right. Modelled on the dashboard hero-trip.
* the big list name, an optional description + link chips, and a Share action
* top-right. Filtering lives in the toolbar above the places, not here.
* Modelled on the dashboard hero-trip.
*/
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
export default function CollectionHero({
eyebrow, title, color, coverImage, description, links, counts, statusFilter, onStatusFilter,
eyebrow, title, color, coverImage, description, links,
members, canShare, isOwner, shareMemberCount, onShare, t,
}: CollectionHeroProps): React.ReactElement {
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
@@ -51,11 +46,6 @@ export default function CollectionHero({
const shown = accepted.slice(0, 5)
const extra = accepted.length - shown.length
const chips: { key: StatusFilter; label: string; color?: string }[] = [
{ key: 'all', label: t('collections.status.filterAll') },
...STATUS_ORDER.map(s => ({ key: s as StatusFilter, label: t(STATUS_META[s].labelKey), color: STATUS_META[s].color })),
]
return (
<header className="col-hero" style={{ ['--hero-color' as string]: color }}>
{coverImage ? (
@@ -97,36 +87,20 @@ export default function CollectionHero({
{extra > 0 && <span className="col-av" style={{ background: 'rgba(255,255,255,.28)' }}>+{extra}</span>}
</span>
)}
{links && links.length > 0 && (
<span className="col-hero-links">
{links.map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-hero-link" onClick={e => e.stopPropagation()}>
<Link2 size={12} /> {l.label || linkHost(l.url)}
</a>
))}
</span>
)}
</div>
<h1 className="col-hero-title">{title}</h1>
{description && <p className="col-hero-desc">{description}</p>}
<div className="col-hero-stats">
{chips.map(chip => (
<button
key={chip.key}
type="button"
onClick={() => onStatusFilter(chip.key)}
className={`col-chip${statusFilter === chip.key ? ' on' : ''}`}
>
{chip.color && <span className="sw" style={{ background: chip.color }} />}
{chip.label}
<span className="n">{counts[chip.key]}</span>
</button>
))}
</div>
{links && links.length > 0 && (
<div className="col-hero-links">
{links.map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-hero-link" onClick={e => e.stopPropagation()}>
<Link2 size={12} /> {l.label || linkHost(l.url)}
</a>
))}
</div>
)}
</div>
</header>
)
@@ -3,6 +3,7 @@ import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import StatusBadge from './StatusBadge'
interface CollectionListProps {
@@ -52,14 +53,27 @@ export default function CollectionList({
)}
<div className="li">
<div className="t">{place.name}</div>
{(place.category?.name || place.address) && (
{place.address && (
<div className="s">
{!place.category?.name && <MapPin size={11} />}
<span>{place.category?.name || place.address}</span>
<MapPin size={11} />
<span>{place.address}</span>
</div>
)}
</div>
<StatusBadge status={place.status} onChange={selectMode ? undefined : next => onStatusChange(place.id, next)} t={t} />
<div className="col-lrow-end">
{place.category?.name && (() => {
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
return (
<>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={11} /> {place.category.name}
</span>
<span className="col-lrow-div" aria-hidden />
</>
)
})()}
<StatusBadge status={place.status} onChange={selectMode ? undefined : next => onStatusChange(place.id, next)} t={t} />
</div>
</div>
)
})}
@@ -32,6 +32,7 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
<MapViewAuto
places={pts}
selectedPlaceId={selectedPlaceId}
hoverDisabled
onMarkerClick={onOpenPlace}
onMapClick={onDeselect ? () => onDeselect() : undefined}
center={center}
@@ -100,6 +100,11 @@ export default function CollectionPlaceDetail({
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
{banner && <img src={banner} alt="" />}
<div className="col-detail-cover-scrim" />
{place.category?.name && (
<span className="col-detail-cover-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} /> {place.category.name}
</span>
)}
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
<div className="col-detail-head">
{editing
@@ -110,14 +115,9 @@ export default function CollectionPlaceDetail({
<div className="col-detail-body">
{/* Meta (view only) */}
{!editing && (place.category?.name || place.address) && (
{!editing && 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>}
<span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>
</div>
)}
+4 -2
View File
@@ -412,6 +412,7 @@ export const MapView = memo(function MapView({
route = null,
routeSegments = [],
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
onMapClick,
onMapContextMenu = null,
@@ -464,9 +465,10 @@ export const MapView = memo(function MapView({
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
if (hoverDisabled) return
setHoveredPlace(place)
setTooltipPos({ x, y })
}, [])
}, [hoverDisabled])
const handleMarkerHoverOut = useCallback(() => {
setHoveredPlace(null)
@@ -601,7 +603,7 @@ export const MapView = memo(function MapView({
} catch { return [] }
}), [places])
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
+8 -1
View File
@@ -68,6 +68,7 @@ interface Props {
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
onMarkerClick?: (id: number) => void
hoverDisabled?: boolean
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
center?: [number, number]
@@ -184,6 +185,7 @@ export function MapViewGL({
route = null,
routeSegments = [],
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
onMapClick,
onMapContextMenu = null,
@@ -264,6 +266,8 @@ export function MapViewGL({
onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick
onClickRefs.current.context = onMapContextMenu
const hoverDisabledRef = useRef(hoverDisabled)
hoverDisabledRef.current = hoverDisabled
// Build/rebuild the map on provider/style/token/3d change
useEffect(() => {
@@ -649,14 +653,17 @@ export function MapViewGL({
onClickRefs.current.marker?.(place.id)
})
el.addEventListener('mouseenter', (ev) => {
if (hoverDisabledRef.current) return
hoverIdRef.current = place.id
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string })
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
})
el.addEventListener('mousemove', (ev) => {
if (hoverDisabledRef.current) return
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
})
el.addEventListener('mouseleave', () => {
if (hoverDisabledRef.current) return
hoverIdRef.current = null
setHoverPlace(null)
setHoverPos(null)
@@ -946,7 +953,7 @@ export function MapViewGL({
)}
{/* Hover tooltip — cursor-following name/category/address card, identical to
the Leaflet map's overlay (no anchored popup, no photo). */}
{hoverPlace && hoverPos && !isMobile && (
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: hoverPos.x + 14,
+5 -1
View File
@@ -19,7 +19,11 @@ export function useElementSize<T extends HTMLElement = HTMLElement>(): {
obs.current?.disconnect()
obs.current = null
if (!el) return
const measure = () => setSize({ width: el.offsetWidth, height: el.offsetHeight })
// Only update state when the size actually changed — avoids re-render churn.
const measure = () => setSize(prev => {
const w = el.offsetWidth, h = el.offsetHeight
return prev.width === w && prev.height === h ? prev : { width: w, height: h }
})
measure()
obs.current = new ResizeObserver(measure)
obs.current.observe(el)
+26 -9
View File
@@ -6,12 +6,12 @@ import ListsRail from '../components/Collections/ListsRail'
import ListEditorModal from '../components/Collections/ListEditorModal'
import CollectionHero from '../components/Collections/CollectionHero'
import CollectionList from '../components/Collections/CollectionList'
import CollectionFilterBar from '../components/Collections/CollectionFilterBar'
import CollectionMapPanel from '../components/Collections/CollectionMapPanel'
import CopyToTripModal from '../components/Collections/CopyToTripModal'
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
import { mappablePlaces } from './collections/collectionsModel'
import { useCollections } from './collections/useCollections'
import '../styles/dashboard.css'
import '../styles/collections.css'
@@ -45,7 +45,7 @@ export default function CollectionsPage(): React.ReactElement {
// clears it. Below the desktop breakpoint the list and map are separate views;
// above it the list view is a split with a persistent map that pans to the
// selection (the map stays mounted across the list↔map toggle so it animates).
const mappable = mappablePlaces(c.visiblePlaces)
const mappable = c.mappable
const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id)
const deselect = () => c.setSelectedPlaceId(null)
const toggleView = () => {
@@ -95,24 +95,44 @@ export default function CollectionsPage(): React.ReactElement {
/>
)
// Filter row + the list (or a "no match" note when a filter hides everything).
// Kept together so the filters stay reachable even when nothing matches.
const filterBar = hasPlaces ? (
<CollectionFilterBar
statusFilter={c.statusFilter}
counts={c.counts}
categoryFilter={c.categoryFilter}
categoryOptions={c.categoryOptions}
onStatusFilter={c.setStatusFilter}
onCategoryFilter={c.setCategoryFilter}
t={t}
/>
) : null
const listColumn = (
<>
{filterBar}
{c.visiblePlaces.length > 0
? listEl
: <EmptyState icon={<Search size={26} />} title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} />}
</>
)
let body: React.ReactElement
if (c.placesLoading && !hasPlaces) {
body = <div className="col-loading"><div className="col-spinner" /></div>
} else if (!hasPlaces) {
body = <EmptyState icon={<Bookmark size={26} />} title={t('collections.empty.title')} text={t('collections.empty.text')} />
} else if (c.visiblePlaces.length === 0) {
body = <EmptyState icon={<Search size={26} />} title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} />
} else if (desktopSplit) {
body = (
<div className={`col-split${c.view === 'map' ? ' map-full' : ''}`}>
<div className="col-split-list" ref={c.listColRef}>{listEl}</div>
<div className="col-split-list" ref={c.listColRef}>{listColumn}</div>
<div className="col-split-map">{mapPanel(true)}</div>
</div>
)
} else if (c.view === 'map' && mappable.length > 0) {
body = <div className="col-mapwrap">{mapPanel(false)}</div>
} else {
body = listEl
body = listColumn
}
const rail = (
@@ -160,9 +180,6 @@ export default function CollectionsPage(): React.ReactElement {
coverImage={heroCover}
description={c.isAllSaved ? null : (c.activeCollection?.description ?? null)}
links={c.isAllSaved ? undefined : c.activeCollection?.links}
counts={c.counts}
statusFilter={c.statusFilter}
onStatusFilter={c.setStatusFilter}
members={c.members}
canShare={c.canShare}
isOwner={c.isOwner}
@@ -49,10 +49,12 @@ export function filterPlaces(
places: CollectionPlace[],
statusFilter: StatusFilter,
search: string,
categoryFilter: number | 'all' = 'all',
): CollectionPlace[] {
const q = search.trim().toLowerCase()
return places.filter(p => {
if (statusFilter !== 'all' && p.status !== statusFilter) return false
if (categoryFilter !== 'all' && (p.category_id ?? null) !== categoryFilter) return false
if (!q) return true
return (
p.name.toLowerCase().includes(q) ||
@@ -69,6 +71,20 @@ export function statusCounts(places: CollectionPlace[]): Record<StatusFilter, nu
return counts
}
export interface CategoryOption { id: number; name: string; color: string | null; icon: string | null; count: number }
/** Distinct categories actually present across the given places, with counts. */
export function presentCategories(places: CollectionPlace[]): CategoryOption[] {
const byId = new Map<number, CategoryOption>()
for (const p of places) {
if (p.category_id == null || !p.category) continue
const existing = byId.get(p.category_id)
if (existing) existing.count += 1
else byId.set(p.category_id, { id: p.category_id, name: p.category.name, color: p.category.color ?? null, icon: p.category.icon ?? null, count: 1 })
}
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))
}
/** Only the places that can render on a map. */
export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
return places.filter(p => typeof p.lat === 'number' && typeof p.lng === 'number')
+13 -8
View File
@@ -12,7 +12,7 @@ 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'
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories } from './collectionsModel'
/**
* Collections page logic — owns the page-local UI state (new/edit-list forms,
@@ -48,13 +48,13 @@ export function useCollections() {
const store = useCollectionStore()
const {
collections, activeId, places, members, incomingInvites,
view, statusFilter, search, selectedPlaceId, selectMode, selectedIds,
view, statusFilter, categoryFilter, search, selectedPlaceId, selectMode, selectedIds,
loading, placesLoading,
loadAll, setActive, refreshActive, loadCollection,
deleteCollection,
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
acceptInvite, declineInvite,
setView, setStatusFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
} = store
// ── Page-local UI state ─────────────────────────────────────────────
@@ -150,9 +150,14 @@ export function useCollections() {
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
const visiblePlaces = useMemo(
() => sortPlaces(filterPlaces(places, statusFilter, search)),
[places, statusFilter, search],
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter)),
[places, statusFilter, search, categoryFilter],
)
// Categories actually present in this list, for the category filter dropdown.
const categoryOptions = useMemo(() => presentCategories(places), [places])
// Stable reference so the map doesn't tear down + rebuild every marker on each
// unrelated re-render (which would swallow marker clicks mid-rebuild).
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
const counts = useMemo(() => statusCounts(places), [places])
// ── Handlers ────────────────────────────────────────────────────────
@@ -286,11 +291,11 @@ export function useCollections() {
// store data
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
canShare, shareMemberCount,
activeId, places, visiblePlaces, members, incomingInvites, counts,
view, statusFilter, search, selectedPlaceId, selectMode, selectedIds,
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
loading, placesLoading,
// store setters
setView, setStatusFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
updatePlace,
// local UI state
editorTarget, setEditorTarget, handleEditorCreated,
+4
View File
@@ -29,6 +29,7 @@ interface CollectionState {
incomingInvites: IncomingCollectionInvite[]
view: CollectionView
statusFilter: StatusFilter
categoryFilter: number | 'all'
search: string
selectedPlaceId: number | null
selectMode: boolean
@@ -62,6 +63,7 @@ interface CollectionState {
setView: (view: CollectionView) => void
setStatusFilter: (filter: StatusFilter) => void
setCategoryFilter: (filter: number | 'all') => void
setSearch: (search: string) => void
setSelectedPlaceId: (id: number | null) => void
setSelectMode: (on: boolean) => void
@@ -77,6 +79,7 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
incomingInvites: [],
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
search: '',
selectedPlaceId: null,
selectMode: false,
@@ -255,6 +258,7 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
setView: (view: CollectionView) => set({ view }),
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
setSearch: (search: string) => set({ search }),
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
+37 -11
View File
@@ -94,7 +94,7 @@
.trek-dash .col-hero {
position: relative; isolation: isolate; overflow: hidden; display: flex;
border-radius: var(--r-2xl); box-shadow: var(--sh-lg);
min-height: 232px; margin-bottom: 26px;
min-height: 148px; margin-bottom: 22px;
}
.trek-dash .col-hero-bg {
position: absolute; inset: 0; z-index: 0;
@@ -110,8 +110,8 @@
linear-gradient(90deg, rgba(0,0,0,.30) 0%, rgba(0,0,0,0) 62%);
}
.trek-dash .col-hero-content {
position: relative; z-index: 2; width: 100%; padding: 26px 30px 24px;
display: flex; flex-direction: column; justify-content: flex-end; gap: 15px; color: #fff;
position: relative; z-index: 2; width: 100%; padding: 18px 28px 20px;
display: flex; flex-direction: column; justify-content: flex-end; gap: 9px; color: #fff;
}
.trek-dash .col-hero-actions { position: absolute; top: 20px; right: 22px; z-index: 3; display: flex; gap: 8px; }
.trek-dash .col-glass-btn {
@@ -128,12 +128,12 @@
background: rgba(255,255,255,.28); font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.trek-dash .col-hero-eyebrow {
display: inline-flex; align-items: center; gap: 9px; font-size: 11.5px;
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 9px; font-size: 11.5px;
letter-spacing: .18em; text-transform: uppercase; font-weight: 600; opacity: .94;
}
.trek-dash .col-hero-eyebrow .members { display: inline-flex; align-items: center; margin-left: 2px; }
.trek-dash .col-hero-title {
font-size: 46px; font-weight: 600; letter-spacing: -.03em; line-height: 1; margin: 0;
font-size: 40px; font-weight: 600; letter-spacing: -.03em; line-height: 1.02; margin: 0;
text-shadow: 0 1px 12px rgba(0,0,0,.3), 0 1px 3px rgba(0,0,0,.35); overflow-wrap: anywhere;
}
/* A light wash of the list colour laid over the cover photo. */
@@ -148,7 +148,7 @@
text-shadow: 0 1px 4px rgba(0,0,0,.4);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.trek-dash .col-hero-links { display: flex; flex-wrap: wrap; gap: 7px; }
.trek-dash .col-hero-links { display: inline-flex; flex-wrap: wrap; gap: 6px; margin-left: 4px; text-transform: none; letter-spacing: normal; }
.trek-dash .col-hero-link {
display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px;
font-size: 12px; font-weight: 500; color: #fff; max-width: 220px;
@@ -219,8 +219,10 @@
/* One top bar so the left cluster (collapse / select) and the right cluster
(add / search) share the width and never overlap on a narrow split map — the
search shrinks instead. */
.trek-dash .col-map-topbar { position: absolute; top: 12px; left: 12px; right: 12px; z-index: 20; display: flex; align-items: center; gap: 10px; }
.trek-dash .col-map-group { display: flex; align-items: center; gap: 8px; }
/* The bar itself is click-through; only the control groups catch clicks, so the
empty space between them never swallows a map/marker click. */
.trek-dash .col-map-topbar { position: absolute; top: 12px; left: 12px; right: 12px; z-index: 20; display: flex; align-items: center; gap: 10px; pointer-events: none; }
.trek-dash .col-map-group { display: flex; align-items: center; gap: 8px; pointer-events: auto; }
.trek-dash .col-map-group.right { margin-left: auto; min-width: 0; }
.trek-dash .col-map-btn {
width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px; display: grid; place-items: center; color: var(--ink);
@@ -260,6 +262,29 @@
.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; }
.trek-dash .col-lrow .li .s span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-lrow-end { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.trek-dash .col-lrow-cat { display: inline-flex; align-items: center; gap: 5px; max-width: 130px; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 14%, transparent); }
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
/* ----------------- filter bar (status + category dropdowns) ----------------- */
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.trek-dash .col-filter { position: relative; }
.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-filter-btn:hover { background: var(--surface-2); color: var(--ink); }
.trek-dash .col-filter-btn.open { border-color: var(--accent); color: var(--ink); }
.trek-dash .col-filter-btn .col-filter-chev { color: var(--ink-3); transition: transform .15s; }
.trek-dash .col-filter-btn.open .col-filter-chev { transform: rotate(180deg); }
.trek-dash .col-filter-lbl { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
.trek-dash .col-filter-pop { position: absolute; top: calc(100% + 6px); left: 0; z-index: 40; min-width: 190px; max-height: 320px; overflow-y: auto; padding: 6px; border-radius: 12px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--sh-lg); display: flex; flex-direction: column; gap: 2px; }
.trek-dash .col-filter-opt { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border-radius: 8px; font-size: 13px; color: var(--ink-2); text-align: left; transition: background .12s; }
.trek-dash .col-filter-opt:hover { background: var(--surface-2); }
.trek-dash .col-filter-opt.on { color: var(--ink); font-weight: 600; }
.trek-dash .col-filter-opt .col-filter-lbl { flex: 1; max-width: none; }
.trek-dash .col-filter-count { font-size: 11px; font-weight: 600; color: var(--ink-3); font-variant-numeric: tabular-nums; }
.trek-dash .col-filter-check { color: var(--accent); flex-shrink: 0; }
.trek-dash .col-filter-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.trek-dash .col-filter-dot.ghost { background: var(--line-2); }
.trek-dash .col-lcheck { width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--line-2); color: transparent; flex-shrink: 0; }
.trek-dash .col-lcheck.on { background: var(--accent); border-color: var(--accent); color: #fff; }
@@ -314,6 +339,7 @@
.trek-dash .col-detail-cover { position: relative; height: 116px; flex-shrink: 0; background-size: cover; background-position: center; }
.trek-dash .col-detail-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.trek-dash .col-detail-cover-scrim { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.12) 0%, rgba(0,0,0,.55) 100%); }
.trek-dash .col-detail-cover-cat { position: absolute; top: 12px; left: 14px; z-index: 2; display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #fff; background: color-mix(in oklch, var(--cat, #6366f1) 82%, black); box-shadow: 0 2px 8px rgba(0,0,0,.3); }
.trek-dash .col-detail-close {
position: absolute; top: 12px; right: 12px; z-index: 2; width: 32px; height: 32px; border-radius: 50%;
display: grid; place-items: center; background: rgba(0,0,0,.4); color: #fff; border: 1px solid rgba(255,255,255,.25);
@@ -400,9 +426,9 @@
@media (max-width: 720px) {
.trek-dash.col-root { padding-top: 0; }
.trek-dash .col-page { padding: 14px 14px 130px; }
.trek-dash .col-hero { min-height: 178px; border-radius: var(--r-xl); margin-bottom: 18px; }
.trek-dash .col-hero-content { padding: 18px 18px 16px; gap: 12px; }
.trek-dash .col-hero-title { font-size: 30px; }
.trek-dash .col-hero { min-height: 128px; border-radius: var(--r-xl); margin-bottom: 16px; }
.trek-dash .col-hero-content { padding: 14px 16px 15px; gap: 8px; }
.trek-dash .col-hero-title { font-size: 28px; }
.trek-dash .col-hero-actions { top: 14px; right: 14px; }
.trek-dash .col-glass-btn span.txt { display: none; }
.trek-dash .col-glass-btn { padding: 0; width: 38px; justify-content: center; }