mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
9fa0ceaf49
The places pool sits one click away from the plan, and a place you already know belongs to today had to go through the day picker afterwards to get there. With a day open, the add button splits: the left half still opens an empty form, the right half opens one that assigns the place to that day on save. The plumbing was already there. placeFormDayId has assigned a freshly created place to a day since #1998; nothing in the places rail ever set it. The main label shortens to "New place" while the split is out, so both buttons fit side by side in the rail without either one truncating. The second button stays mounted and collapsed rather than unmounted, so it has something to animate out of: width, padding and opacity over 180ms, and nothing at all under prefers-reduced-motion. Collapsed it is also out of the tab order and hidden from screen readers, because a zero-width button is still focusable otherwise. Both columns of the place form are 320px now. Two panels of different widths flanking one form read as a mistake rather than as a hierarchy, and neither of them is the more important one.
258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Search, Bookmark, Loader2, ChevronDown, Check, Layers } from 'lucide-react'
|
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
|
import { collectionsApi } from '../../api/collections'
|
|
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
|
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
|
import type { TranslationFn } from '../../types'
|
|
import EmptyState from '../shared/EmptyState'
|
|
|
|
interface LocationBias {
|
|
low: { lat: number; lng: number }
|
|
high: { lat: number; lng: number }
|
|
}
|
|
|
|
interface ListMeta { id: number; name: string; color: string | null }
|
|
|
|
interface CollectionPickerProps {
|
|
/** Trip bounding box used for autocomplete — sorts the saved places by
|
|
* proximity to the trip so the relevant ones surface first. */
|
|
bias?: LocationBias
|
|
/** Fills the place form from the chosen saved place (handleSelectMapsResult). */
|
|
onSelect: (place: CollectionPlace) => void
|
|
t: TranslationFn
|
|
}
|
|
|
|
function distanceTo(p: CollectionPlace, center: { lat: number; lng: number }): number {
|
|
if (p.lat == null || p.lng == null) return Number.POSITIVE_INFINITY
|
|
const dlat = p.lat - center.lat
|
|
const dlng = p.lng - center.lng
|
|
return dlat * dlat + dlng * dlng
|
|
}
|
|
|
|
interface Opt { key: string | number; label: string; icon?: React.ReactNode; count?: number }
|
|
|
|
/** Detail requests fired at once while building the union — keeps a user with
|
|
* many lists from opening the modal with a burst of parallel requests. */
|
|
const DETAIL_BATCH = 4
|
|
|
|
/** Union of every list's places, in list order, without duplicates. */
|
|
async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
|
|
const merged: CollectionPlace[] = []
|
|
const seen = new Set<number>()
|
|
for (let i = 0; i < ids.length; i += DETAIL_BATCH) {
|
|
const batch = await Promise.all(ids.slice(i, i + DETAIL_BATCH).map(id => collectionsApi.get(id).catch(() => null)))
|
|
for (const d of batch) {
|
|
if (!d) continue
|
|
for (const p of d.places) {
|
|
if (seen.has(p.id)) continue
|
|
seen.add(p.id)
|
|
merged.push(p)
|
|
}
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
/**
|
|
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
|
|
*
|
|
* The panel is positioned against the FILTER ROW rather than against this
|
|
* button, so it opens across the full width of the row instead of the half its
|
|
* own trigger occupies. The two filters sit side by side and only one can be
|
|
* open at a time, so the wider panel costs nothing and stops list names being
|
|
* truncated at roughly ten characters. The row carries the `relative` this
|
|
* needs; there is no other call site.
|
|
*/
|
|
function FilterDropdown({ 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="min-w-0 flex-1" ref={ref}>
|
|
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
|
|
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
|
|
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
|
|
<span className="flex-1 min-w-0 truncate text-left">{cur.label}</span>
|
|
<ChevronDown size={13} className="shrink-0 text-content-faint" />
|
|
</button>
|
|
{open && (
|
|
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
|
{options.map(o => (
|
|
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
|
|
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
|
<span className="shrink-0 text-content-faint">{o.icon}</span>
|
|
<span className="flex-1 min-w-0 truncate">{o.label}</span>
|
|
{o.count != null && <span className="shrink-0 text-[11px] text-content-faint tabular-nums">{o.count}</span>}
|
|
{o.key === current && <Check size={13} className="shrink-0 text-accent" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Right-hand column of the desktop add-place modal: the user's saved collection
|
|
* places, searchable, filterable by list + status, and proximity-sorted, so a
|
|
* place saved on an earlier trip can be dropped straight into the form.
|
|
* Desktop only — gated by the caller.
|
|
*/
|
|
export default function CollectionPicker({ bias, onSelect, t }: CollectionPickerProps): React.ReactElement {
|
|
const [places, setPlaces] = useState<CollectionPlace[]>([])
|
|
const [lists, setLists] = useState<ListMeta[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [search, setSearch] = useState('')
|
|
const [listFilter, setListFilter] = useState<number | 'all'>('all')
|
|
const [statusFilter, setStatusFilter] = useState<CollectionStatus | 'all'>('all')
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoading(true)
|
|
collectionsApi.list()
|
|
.then(async (res) => {
|
|
const merged = await loadSavedPlaces(res.collections.map(c => c.id))
|
|
if (cancelled) return
|
|
setLists(res.collections.map(c => ({ id: c.id, name: c.name, color: c.color ?? null })))
|
|
setPlaces(merged)
|
|
})
|
|
.catch(() => { if (!cancelled) { setPlaces([]); setLists([]) } })
|
|
.finally(() => { if (!cancelled) setLoading(false) })
|
|
return () => { cancelled = true }
|
|
}, [])
|
|
|
|
const center = useMemo(
|
|
() => (bias ? { lat: (bias.low.lat + bias.high.lat) / 2, lng: (bias.low.lng + bias.high.lng) / 2 } : null),
|
|
[bias],
|
|
)
|
|
|
|
// The list is every saved place across every collection, which grows without
|
|
// bound. Show a first page and let the rest be asked for.
|
|
const PAGE = 10
|
|
const [shown, setShown] = useState(PAGE)
|
|
|
|
const visible = useMemo(() => {
|
|
const q = search.trim().toLowerCase()
|
|
const list = places.filter(p => {
|
|
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
|
|
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
|
if (!q) return true
|
|
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
|
|
})
|
|
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
|
|
else list.sort((a, b) => a.name.localeCompare(b.name))
|
|
return list
|
|
}, [places, search, center, listFilter, statusFilter])
|
|
|
|
// Searching or filtering starts a new list; keeping the old offset would drop
|
|
// the user somewhere in the middle of it.
|
|
useEffect(() => { setShown(PAGE) }, [search, listFilter, statusFilter])
|
|
|
|
const page = visible.slice(0, shown)
|
|
const remaining = visible.length - page.length
|
|
|
|
const listOpts: Opt[] = [
|
|
{ key: 'all', label: t('collections.picker.allLists'), icon: <Layers size={13} />, count: places.length },
|
|
...lists.map(l => ({
|
|
key: l.id,
|
|
label: l.name,
|
|
icon: <span className="w-2.5 h-2.5 rounded-full inline-block" style={{ background: l.color || '#6366f1' }} />,
|
|
count: places.filter(p => p.collection_id === l.id).length,
|
|
})),
|
|
]
|
|
const statusOpts: Opt[] = [
|
|
{ key: 'all', label: t('common.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 }} /> }
|
|
}),
|
|
]
|
|
|
|
return (
|
|
// Same 320px as the details column on the other side of the form: two panels
|
|
// of different widths flanking one form read as a mistake rather than a
|
|
// hierarchy, and neither of them is the more important one.
|
|
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
|
<Bookmark size={15} className="text-accent" />
|
|
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
|
|
</div>
|
|
<div className="p-2.5 flex flex-col gap-2 shrink-0">
|
|
<div className="relative">
|
|
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
|
|
<input
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
placeholder={t('collections.picker.search')}
|
|
className="w-full pl-8 pr-3 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
{lists.length > 0 && (
|
|
<div className="relative flex gap-2">
|
|
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
|
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-10 text-content-faint">
|
|
<Loader2 size={18} className="animate-spin" />
|
|
</div>
|
|
) : visible.length === 0 ? (
|
|
<EmptyState
|
|
scene="search"
|
|
title={t('collections.picker.empty')}
|
|
size={84}
|
|
fill
|
|
surface="var(--bg-secondary)"
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
{page.map(place => (
|
|
<button
|
|
key={place.id}
|
|
type="button"
|
|
onClick={() => onSelect(place)}
|
|
title={t('collections.picker.use')}
|
|
className="flex items-center gap-2.5 px-2 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors"
|
|
>
|
|
<PlaceAvatar place={place} size={32} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
|
|
<span className="flex flex-col min-w-0">
|
|
<span className="text-[12.5px] font-medium text-content truncate">{place.name}</span>
|
|
{place.address && <span className="text-[11px] text-content-faint truncate">{place.address}</span>}
|
|
</span>
|
|
</button>
|
|
))}
|
|
{remaining > 0 && (
|
|
<div className="pt-1 mt-1 border-t border-edge">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShown(n => n + PAGE)}
|
|
className="w-full px-2 py-1.5 rounded-lg text-[12px] font-medium text-content-secondary hover:bg-surface-hover transition-colors"
|
|
>
|
|
{t('collections.picker.showMore', { count: remaining })}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|