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 { const merged: CollectionPlace[] = [] const seen = new Set() 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(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 (
{open && (
{options.map(o => ( ))}
)}
) } /** * 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([]) const [lists, setLists] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [listFilter, setListFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('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: , count: places.length }, ...lists.map(l => ({ key: l.id, label: l.name, icon: , 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: } }), ] return ( ) }