mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
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.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user