mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : [] }),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user