feat(navi): GPS origin + place detail panel + basic actions

Adds synthetic "Your location" stop A when GPS granted; place
detail panel slides in on search result click with Directions /
Add stop / Save (stub) / Share actions; elevation via Valhalla
/height; react-hot-toast for feedback; pendingDestination state
for GPS-denied Directions flow.

Phase 3 Step 5 C1 of Navi.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-20 20:59:18 +00:00
commit 02f2b25db3
18 changed files with 1207 additions and 274 deletions

View file

@ -1,4 +1,5 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore } from '../store'
import SearchBar from './SearchBar'
import StopList from './StopList'
@ -18,6 +19,11 @@ export default function Panel({ onManeuverClick }) {
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
@ -33,18 +39,32 @@ export default function Panel({ onManeuverClick }) {
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (stops.length < 3 || optimizing) return
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
// Reorder stops based on optimized waypoint order
const wpOrder = data.trip.locations
// If GPS origin was prepended, skip it from the result waypoints
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
// Match optimized locations back to original stops by proximity
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
@ -57,7 +77,6 @@ export default function Panel({ onManeuverClick }) {
}
return closest
})
// Deduplicate (in case of matching issues)
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
@ -75,7 +94,7 @@ export default function Panel({ onManeuverClick }) {
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, setStops, setRoute, setRouteError])
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
@ -86,30 +105,25 @@ export default function Panel({ onManeuverClick }) {
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
// Swipe up
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
// Swipe down
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = stops.length >= 3
const showOptimize = effectiveCount >= 3
const content = (
<>
<SearchBar />
{/* Stop list */}
<div className="mt-3">
<StopList />
</div>
{/* Mode selector + optimize */}
{stops.length >= 1 && (
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
@ -117,7 +131,7 @@ export default function Panel({ onManeuverClick }) {
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="w-full py-1.5 px-3 text-xs font-medium bg-yellow-700 hover:bg-yellow-600 text-white rounded disabled:opacity-50 transition-colors"
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
@ -125,28 +139,46 @@ export default function Panel({ onManeuverClick }) {
</div>
)}
{/* Maneuver list */}
{(route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* TODO: Recents / saved places placeholder */}
{stops.length === 0 && !route && (
<div className="mt-6 text-center text-gray-600 text-xs">
{/* TODO: Wire recents + favorites in a later phase */}
<p>Recent places will appear here</p>
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search and add stops to build your route</p>
</div>
)}
</>
)
const header = (
<div className="flex items-center justify-between mb-3">
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
<button
onClick={toggleTheme}
className="p-1.5 rounded"
style={{ color: 'var(--text-secondary)' }}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</div>
)
// Desktop: side panel
if (!isMobile) {
return (
<div className="absolute top-0 left-0 z-10 w-80 h-full bg-gray-900/95 backdrop-blur-sm border-r border-gray-700 overflow-y-auto p-4 flex flex-col">
<h1 className="text-lg font-semibold text-cyan-400 mb-3">Navi</h1>
<div
className="absolute top-0 left-0 z-10 w-80 h-full overflow-y-auto p-4 flex flex-col"
style={{
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
@ -162,7 +194,11 @@ export default function Panel({ onManeuverClick }) {
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 bg-gray-900/95 backdrop-blur-sm border-t border-gray-700 rounded-t-2xl transition-all duration-300 ${sheetHeights[sheetState]}`}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
@ -175,11 +211,12 @@ export default function Panel({ onManeuverClick }) {
else setSheetState('half')
}}
>
<div className="w-10 h-1 bg-gray-600 rounded-full" />
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto h-[calc(100%-2rem)]">
{header}
{content}
</div>
)}