Files
TREK/client/src/components/Collections/CollectionPicker.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

281 lines
14 KiB
TypeScript

import React, { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Search, Bookmark, Loader2, ChevronDown, Check, Layers, Plus, SearchX } 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 { fs } from '../shared/DialogShell'
import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
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({ label, current, options, onSelect, lead }: {
/** What the filter filters, as the small label over it and as the name of its list. */
label: string
current: string | number
options: Opt[]
onSelect: (key: string | number) => void
lead: React.ReactNode
}): React.ReactElement {
const labelId = useId()
const valueId = useId()
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) }
// Captured and marked handled, so the key closes this list and not the dialog around it.
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
setOpen(false)
}
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey, true)
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey, true) }
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="min-w-0 flex-1" ref={ref}>
<span id={labelId} className={LABEL}>{label}</span>
{/* A white pill, like the pickers on the dialog's head band. Named by the
label over it and the value it shows, so "All" says what it is all of. */}
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
aria-labelledby={`${labelId} ${valueId}`}
className={`flex w-full items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 transition-colors hover:bg-surface-hover ${open ? 'ring-content-faint' : 'ring-edge-faint'}`}
style={fs(12, 'body')}>
<span className="flex flex-none text-content-faint">{cur.icon ?? lead}</span>
<span id={valueId} className="min-w-0 flex-1 truncate text-left">{cur.label}</span>
<ChevronDown size={13} strokeWidth={2.2} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div role="listbox" aria-label={label} className="absolute left-0 right-0 top-full z-30 mt-1 flex max-h-[240px] flex-col gap-0.5 overflow-y-auto rounded-[12px] border border-edge-faint bg-surface-card p-1 shadow-dropdown">
{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 rounded-[8px] px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'bg-surface-tertiary font-semibold text-content' : 'text-content-secondary'}`}
style={fs(12.5, 'body')}>
<span className="flex flex-none text-content-faint">{o.icon}</span>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
{o.count != null && <span className="flex-none tabular-nums text-content-faint" style={fs(11)}>{o.count}</span>}
{o.key === current && <Check size={13} className="flex-none text-content-muted" />}
</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 searchId = useId()
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="inline-block h-2.5 w-2.5 rounded-full" style={{ background: l.color || '#6366f1' }} />, // theme-lint-disable: the list's own colour, and the default a list without one is drawn in (ListsRail)
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={`${SIDE_COLUMN} sm:w-80`}>
<ColumnHead icon={<Bookmark size={13} strokeWidth={2.2} />}>{t('collections.picker.title')}</ColumnHead>
<div className="flex flex-none flex-col gap-2 px-3 pb-2.5">
<EditorField label={t('common.search')} htmlFor={searchId}>
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
id={searchId}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
// Named after what it searches: the form beside it has a search of its own.
aria-label={t('collections.picker.search')}
placeholder={t('collections.picker.search')}
className={`${INPUT} pl-8`}
/>
</div>
</EditorField>
{lists.length > 0 && (
<div className="relative flex gap-2">
<FilterDropdown label={t('collections.title')} current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
<FilterDropdown label={t('mobileCollections.status')} current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{loading ? (
<div className="px-1">
<HintCard icon={<Loader2 size={15} className="animate-spin" />}>{t('common.loading')}</HintCard>
</div>
) : visible.length === 0 ? (
<div className="px-1">
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('collections.picker.empty')}</HintCard>
</div>
) : (
<div className="flex flex-col gap-0.5">
{page.map(place => (
// Built like a row of the planner's places list: the round picture
// or category disc, the name, and the address on one line under it.
<button
key={place.id}
type="button"
onClick={() => onSelect(place)}
className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-2 text-left outline-none transition-colors hover:bg-surface-card hover:shadow-sm focus-visible:bg-surface-card focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] active:bg-surface-selected"
>
<PlaceAvatar place={place} size={34} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{place.name}</span>
{place.address && <span className="mt-0.5 truncate text-content-faint" style={fs(11)}>{place.address}</span>}
</span>
{/* What a click does, said the way the planner's rows say it. */}
<span aria-hidden="true" className="grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card text-content-muted opacity-0 shadow-sm ring-1 ring-edge-faint transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-100">
<Plus size={13} strokeWidth={2.4} />
</span>
</button>
))}
{remaining > 0 && (
<div className="mt-1.5 px-1">
<button
type="button"
onClick={() => setShown(n => n + PAGE)}
className={WHITE_BUTTON}
style={fs(12, 'body')}
>
{t('collections.picker.showMore', { count: remaining })}
</button>
</div>
)}
</div>
)}
</div>
</aside>
)
}