mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
8defc90e95
Adds from/to endpoints to flight/train/cruise/car reservations with live map rendering. Flights use geodesic arcs and a curved duration + distance badge; train/car/cruise render as straight or geodesic lines with endpoint markers. Airports come from an embedded OurAirports database (~3200 airports, offline-capable); train/cruise/car locations via Nominatim. Per-trip connection toggle sits in the day plan sidebar, persisted in localStorage. Clicking a map endpoint opens the existing transport detail popup. New display setting toggles endpoint labels on the map. Migration 105 adds the reservation_endpoints table plus needs_review flag; existing flights are backfilled from their IATA metadata on server startup.
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { MapPin, X } from 'lucide-react'
|
|
import { mapsApi } from '../../api/client'
|
|
import { useTranslation } from '../../i18n'
|
|
|
|
export interface LocationPoint {
|
|
name: string
|
|
lat: number
|
|
lng: number
|
|
address?: string | null
|
|
}
|
|
|
|
interface Props {
|
|
value: LocationPoint | null
|
|
onChange: (loc: LocationPoint | null) => void
|
|
placeholder?: string
|
|
style?: React.CSSProperties
|
|
}
|
|
|
|
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
|
|
const { t, locale } = useTranslation()
|
|
const [query, setQuery] = useState(value?.name || '')
|
|
const [open, setOpen] = useState(false)
|
|
const [results, setResults] = useState<any[]>([])
|
|
const [highlight, setHighlight] = useState(-1)
|
|
const [loading, setLoading] = useState(false)
|
|
const wrapRef = useRef<HTMLDivElement>(null)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
useEffect(() => {
|
|
setQuery(value?.name || '')
|
|
}, [value])
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
if (open) document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
const trimmed = query.trim()
|
|
if (trimmed.length < 3 || (value && trimmed === value.name)) {
|
|
setResults([])
|
|
return
|
|
}
|
|
debounceRef.current = setTimeout(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await mapsApi.search(trimmed, locale)
|
|
setResults(data.places || [])
|
|
setHighlight(-1)
|
|
} catch {
|
|
setResults([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, 320)
|
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
|
}, [query, value, locale])
|
|
|
|
const pick = (r: any) => {
|
|
const lat = Number(r.lat)
|
|
const lng = Number(r.lng)
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
|
const loc: LocationPoint = { name: r.name || r.address || 'Location', lat, lng, address: r.address || null }
|
|
onChange(loc)
|
|
setQuery(loc.name)
|
|
setOpen(false)
|
|
setResults([])
|
|
}
|
|
|
|
const clear = () => {
|
|
onChange(null)
|
|
setQuery('')
|
|
setResults([])
|
|
}
|
|
|
|
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (!open || results.length === 0) return
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
|
|
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
|
|
else if (e.key === 'Escape') setOpen(false)
|
|
}
|
|
|
|
return (
|
|
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
|
|
<MapPin size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
placeholder={placeholder ?? t('reservations.searchLocation')}
|
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
|
|
onFocus={() => setOpen(true)}
|
|
onKeyDown={onKey}
|
|
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
|
|
/>
|
|
{value && (
|
|
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{open && (loading || results.length > 0) && (
|
|
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
|
|
{loading && results.length === 0 && (
|
|
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
|
|
)}
|
|
{results.map((r, i) => (
|
|
<button
|
|
key={`${r.osm_id || r.google_place_id || i}`}
|
|
type="button"
|
|
onClick={() => pick(r)}
|
|
onMouseEnter={() => setHighlight(i)}
|
|
style={{
|
|
display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%',
|
|
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
|
|
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
|
|
color: 'var(--text-primary)', fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
<MapPin size={12} style={{ color: 'var(--text-faint)', marginTop: 2, flexShrink: 0 }} />
|
|
<span style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div>
|
|
{r.address && r.name !== r.address && (
|
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div>
|
|
)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|