feat: search, multi-stop routing, and route display

Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints

Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-20 16:50:53 +00:00
commit e7b08a7dc9
16 changed files with 1364 additions and 44 deletions

View file

@ -0,0 +1,58 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { useStore } from '../store'
import StopItem from './StopItem'
export default function StopList() {
const stops = useStore((s) => s.stops)
const reorderStops = useStore((s) => s.reorderStops)
const geoPermission = useStore((s) => s.geoPermission)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragEnd(event) {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = stops.findIndex((s) => s.id === active.id)
const newIndex = stops.findIndex((s) => s.id === over.id)
reorderStops(arrayMove(stops, oldIndex, newIndex))
}
if (stops.length === 0) {
return (
<div className="text-gray-500 text-xs px-2 py-3 text-center">
{geoPermission === 'denied'
? 'Add a starting point and destination above'
: 'Search and add stops to build your route'}
</div>
)
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-1">
{stops.map((stop, i) => (
<StopItem key={stop.id} stop={stop} index={i} total={stops.length} />
))}
</div>
</SortableContext>
</DndContext>
)
}