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

@ -0,0 +1,33 @@
/** Non-draggable "Your location" row at top of StopList when GPS is granted. */
export default function GpsOriginItem() {
return (
<div
className="flex items-center gap-2 py-1.5 px-2 rounded"
style={{
background: 'var(--bg-overlay)',
border: '1px solid var(--border-subtle)',
}}
>
{/* Spacer matching drag handle width */}
<span className="w-[14px]" />
{/* ATAK chevron icon */}
<span className="w-5 h-5 flex items-center justify-center shrink-0">
<svg width="14" height="14" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 1 L14 13 L8 10 L2 13 Z"
fill="var(--accent)"
stroke="var(--pin-stroke)"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
</span>
{/* Label */}
<span className="flex-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
Your location
</span>
</div>
)
}

View file

@ -1,6 +1,10 @@
import {
MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft,
MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw,
GitMerge, CornerRightDown, CornerRightUp, Navigation
} from 'lucide-react'
import { useStore } from '../store'
/** Format seconds into human-friendly string */
function formatTime(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`
if (seconds < 3600) return `${Math.round(seconds / 60)} min`
@ -9,34 +13,30 @@ function formatTime(seconds) {
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
/** Format distance in miles */
function formatDist(miles) {
if (miles < 0.1) return `${Math.round(miles * 5280)} ft`
return `${miles.toFixed(1)} mi`
}
/** Get a maneuver type icon */
function maneuverIcon(type) {
function ManeuverIcon({ type }) {
const size = 16
const props = { size, strokeWidth: 1.5 }
switch (type) {
case 0: return '→' // straight
case 1: return '↗' // slight right
case 2: return '→' // right
case 3: return '↘' // sharp right
case 4: return '↩' // u-turn right
case 5: return '↩' // u-turn left
case 6: return '↙' // sharp left
case 7: return '←' // left
case 8: return '↖' // slight left
case 9: return '●' // depart
case 10: return '●' // arrive (straight)
case 11: return '●' // arrive (right)
case 12: return '●' // arrive (left)
case 15: return '◎' // roundabout enter
case 16: return '◎' // roundabout exit
case 24: return '▲' // merge
case 25: return '⤴' // on ramp
case 26: return '⤵' // off ramp
default: return '→'
case 0: return <MoveRight {...props} />
case 1: return <MoveUpRight {...props} />
case 2: return <CornerUpRight {...props} />
case 3: return <MoveDownRight {...props} />
case 4: case 5: return <CornerUpLeft {...props} />
case 6: return <MoveDownLeft {...props} />
case 7: return <CornerUpLeft {...props} />
case 8: return <MoveUpLeft {...props} />
case 9: return <Navigation {...props} />
case 10: case 11: case 12: return <CircleDot {...props} />
case 15: case 16: return <RotateCw {...props} />
case 24: return <GitMerge {...props} />
case 25: return <CornerRightUp {...props} />
case 26: return <CornerRightDown {...props} />
default: return <MoveRight {...props} />
}
}
@ -48,15 +48,27 @@ export default function ManeuverList({ onManeuverClick }) {
if (routeLoading) {
return (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
<span className="ml-2 text-sm text-gray-400">Calculating route...</span>
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
/>
<span className="ml-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
Calculating route...
</span>
</div>
)
}
if (routeError) {
return (
<div className="px-3 py-2 bg-red-900/30 border border-red-700 rounded text-sm text-red-300">
<div
className="px-3 py-2 rounded text-sm"
style={{
background: 'color-mix(in srgb, var(--status-danger) 15%, transparent)',
border: '1px solid var(--status-danger)',
color: 'var(--status-danger)',
}}
>
{routeError}
</div>
)
@ -64,22 +76,16 @@ export default function ManeuverList({ onManeuverClick }) {
if (!route || !route.legs) return null
// Compute total summary
const totalTime = route.summary?.time || 0
const totalDist = route.summary?.length || 0
// Flatten all maneuvers with cumulative time remaining
const allManeuvers = []
let timeRemaining = totalTime
for (let legIdx = 0; legIdx < route.legs.length; legIdx++) {
const leg = route.legs[legIdx]
for (const man of leg.maneuvers || []) {
allManeuvers.push({
...man,
_legIndex: legIdx,
timeRemaining,
})
allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining })
timeRemaining -= man.time || 0
}
}
@ -87,38 +93,42 @@ export default function ManeuverList({ onManeuverClick }) {
return (
<div className="flex flex-col">
{/* Route summary */}
<div className="flex items-center justify-between px-3 py-2 bg-gray-800/60 rounded mb-2">
<span className="text-sm font-medium text-white">
<div
className="flex items-center justify-between px-3 py-2 rounded mb-2"
style={{ background: 'var(--bg-overlay)', border: '1px solid var(--border-subtle)' }}
>
<span className="font-mono text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{formatDist(totalDist)}
</span>
<span className="text-sm text-gray-300">
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
{formatTime(totalTime)}
</span>
</div>
{/* Maneuver steps */}
<div className="flex flex-col divide-y divide-gray-700 max-h-[50vh] overflow-y-auto">
<div className="flex flex-col max-h-[50vh] overflow-y-auto">
{allManeuvers.map((man, i) => (
<button
key={i}
onClick={() => {
if (man.begin_shape_index != null && onManeuverClick) {
onManeuverClick(man)
}
if (man.begin_shape_index != null && onManeuverClick) onManeuverClick(man)
}}
className="flex items-start gap-2 px-2 py-2 text-left hover:bg-gray-800/60 transition-colors"
className="flex items-start gap-2 px-2 py-2 text-left rounded transition-colors duration-75"
style={{ '--hover-bg': 'var(--bg-overlay)' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-overlay)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<span className="text-base w-6 text-center shrink-0 text-cyan-400">
{maneuverIcon(man.type)}
<span className="w-5 shrink-0 mt-0.5" style={{ color: 'var(--accent)' }}>
<ManeuverIcon type={man.type} />
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-200 leading-tight">
<p className="text-sm leading-tight" style={{ color: 'var(--text-primary)' }}>
{man.instruction || man.verbal_pre_transition_instruction || 'Continue'}
</p>
<p className="text-[11px] text-gray-500 mt-0.5">
<p className="font-mono text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{formatDist(man.length || 0)}
{man.timeRemaining > 0 && (
<span className="ml-2">{formatTime(man.timeRemaining)} remaining</span>
<span className="ml-2">{formatTime(man.timeRemaining)} left</span>
)}
</p>
</div>

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react'
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Protocol } from 'pmtiles'
@ -8,17 +8,46 @@ import { decodePolyline } from '../utils/decode'
const ROUTE_SOURCE = 'route-source'
const ROUTE_LAYER_PREFIX = 'route-layer-'
const STOPS_SOURCE = 'stops-source'
const STOPS_LAYER = 'stops-layer'
const MapView = forwardRef(function MapView({ onMapClick }, ref) {
/** Build a full MapLibre style object for the given theme */
function buildStyle(themeName) {
return {
version: 8,
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
sprite: `https://protomaps.github.io/basemaps-assets/sprites/v4/${themeName}`,
sources: {
protomaps: {
type: 'vector',
url: 'pmtiles:///tiles/na.pmtiles',
attribution:
'<a href="https://protomaps.com">Protomaps</a> | <a href="https://openstreetmap.org">OSM</a>',
},
},
layers: layers('protomaps', namedTheme(themeName), { lang: 'en' }),
}
}
/** SVG for ATAK-style chevron pointing up (will be rotated via CSS) */
const CHEVRON_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/>
</svg>`
const MapView = forwardRef(function MapView(_, ref) {
const mapRef = useRef(null)
const mapInstance = useRef(null)
const markersRef = useRef([])
const popupRef = useRef(null)
const gpsMarkerRef = useRef(null)
const previewMarkerRef = useRef(null)
const watchIdRef = useRef(null)
const currentThemeRef = useRef('dark')
const stops = useStore((s) => s.stops)
const route = useStore((s) => s.route)
const theme = useStore((s) => s.theme)
const selectedPlace = useStore((s) => s.selectedPlace)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const setSheetState = useStore((s) => s.setSheetState)
// Expose map methods to parent
@ -36,59 +65,56 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
// Default center: Matt's home (Filer, ID) updated by geolocation if permitted
const DEFAULT_CENTER = [-114.6066, 42.5736]
const DEFAULT_ZOOM = 10
const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark'
currentThemeRef.current = initialTheme
const map = new maplibregl.Map({
container: mapRef.current,
style: {
version: 8,
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v4/dark',
sources: {
protomaps: {
type: 'vector',
url: 'pmtiles:///tiles/na.pmtiles',
attribution:
'<a href="https://protomaps.com">Protomaps</a> | <a href="https://openstreetmap.org">OSM</a>',
},
},
layers: layers('protomaps', namedTheme('dark'), { lang: 'en' }),
},
style: buildStyle(initialTheme),
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
// Request geolocation for initial view (not for routing that's separate)
// GPS tracking creates chevron or dot marker
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude } = pos.coords
// Only fly to user location if no stops have been added yet
if (useStore.getState().stops.length === 0) {
map.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
}
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
useStore.getState().setGeoPermission('granted')
createOrUpdateGpsMarker(map, latitude, longitude, null)
},
() => {
useStore.getState().setGeoPermission('denied')
},
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
)
// Watch for heading changes
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
const { latitude, longitude, heading } = pos.coords
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
createOrUpdateGpsMarker(map, latitude, longitude, heading)
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000 }
)
}
map.on('click', () => {
// Mobile: collapse sheet when map is tapped
if (window.innerWidth < 768) {
setSheetState('collapsed')
}
if (window.innerWidth < 768) setSheetState('collapsed')
useStore.getState().clearSelectedPlace()
})
// Add empty route source on load
map.on('load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
@ -99,24 +125,116 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
mapInstance.current = map
return () => {
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
maplibregl.removeProtocol('pmtiles')
map.remove()
}
}, [setSheetState])
/** Create or update the GPS chevron/dot marker */
function createOrUpdateGpsMarker(map, lat, lon, heading) {
if (!gpsMarkerRef.current) {
const el = document.createElement('div')
if (heading != null && !isNaN(heading)) {
el.className = 'navi-chevron'
el.innerHTML = CHEVRON_SVG
el.style.transform = `rotate(${heading}deg)`
} else {
el.className = 'navi-gps-dot'
}
gpsMarkerRef.current = new maplibregl.Marker({ element: el })
.setLngLat([lon, lat])
.addTo(map)
} else {
gpsMarkerRef.current.setLngLat([lon, lat])
const el = gpsMarkerRef.current.getElement()
if (heading != null && !isNaN(heading)) {
if (!el.classList.contains('navi-chevron')) {
el.className = 'navi-chevron'
el.innerHTML = CHEVRON_SVG
}
el.style.transform = `rotate(${heading}deg)`
} else {
if (!el.classList.contains('navi-gps-dot')) {
el.className = 'navi-gps-dot'
el.innerHTML = ''
}
}
}
}
// Swap map theme when store.theme changes
useEffect(() => {
const map = mapInstance.current
if (!map || currentThemeRef.current === theme) return
currentThemeRef.current = theme
const center = map.getCenter()
const zoom = map.getZoom()
const bearing = map.getBearing()
const pitch = map.getPitch()
map.setStyle(buildStyle(theme), { diff: false })
// Re-add route source after style swap
map.once('style.load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
// Restore view
map.jumpTo({ center, zoom, bearing, pitch })
// Re-render route if exists
const currentRoute = useStore.getState().route
if (currentRoute) updateRoute(map, currentRoute)
})
}, [theme])
// Preview pin for selected place
useEffect(() => {
const map = mapInstance.current
if (!map) return
// Remove old preview marker
if (previewMarkerRef.current) {
previewMarkerRef.current.remove()
previewMarkerRef.current = null
}
if (!selectedPlace) return
// Fly to selected place
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
// Create preview marker
const el = document.createElement('div')
el.className = 'navi-pin-preview'
previewMarkerRef.current = new maplibregl.Marker({ element: el })
.setLngLat([selectedPlace.lon, selectedPlace.lat])
.addTo(map)
return () => {
if (previewMarkerRef.current) {
previewMarkerRef.current.remove()
previewMarkerRef.current = null
}
}
}, [selectedPlace])
// Update route polyline when route changes
useEffect(() => {
const map = mapInstance.current
if (!map || !map.isStyleLoaded()) {
// Wait for style to load
const handler = () => updateRoute(map)
map?.on('load', handler)
return () => map?.off('load', handler)
if (!map) return
if (!map.isStyleLoaded()) {
const handler = () => updateRoute(map, route)
map.once('load', handler)
return () => map.off('load', handler)
}
updateRoute(map)
updateRoute(map, route)
}, [route])
function updateRoute(map) {
function updateRoute(map, routeData) {
if (!map) return
// Remove old route layers
@ -129,19 +247,16 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
}
}
if (!route || !route.legs) {
if (!routeData || !routeData.legs) {
if (map.getSource(ROUTE_SOURCE)) {
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
}
return
}
// Build GeoJSON features from route legs
const features = []
const legColors = ['#22d3ee', '#06b6d4', '#0891b2', '#0e7490', '#155e75']
for (let i = 0; i < route.legs.length; i++) {
const leg = route.legs[i]
for (let i = 0; i < routeData.legs.length; i++) {
const leg = routeData.legs[i]
if (!leg.shape) continue
const coords = decodePolyline(leg.shape, 6)
features.push({
@ -161,7 +276,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
})
}
// Add route layers (one per leg for color variation)
// Use CSS variable for route color (read computed value)
const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim()
for (let i = 0; i < features.length; i++) {
const layerId = `${ROUTE_LAYER_PREFIX}${i}`
if (!map.getLayer(layerId)) {
@ -170,12 +287,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
type: 'line',
source: ROUTE_SOURCE,
filter: ['==', ['get', 'legIndex'], i],
layout: {
'line-join': 'round',
'line-cap': 'round',
},
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': legColors[i % legColors.length],
'line-color': routeColor || '#7a9a6b',
'line-width': 5,
'line-opacity': 0.85,
},
@ -190,7 +304,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
(b, c) => b.extend(c),
new maplibregl.LngLatBounds(allCoords[0], allCoords[0])
)
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
const hasDetail = useStore.getState().selectedPlace != null
const leftPad = hasDetail ? 700 : 340
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } })
}
}
@ -207,22 +323,21 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
popupRef.current = null
}
stops.forEach((stop, i) => {
let color = '#3b82f6' // blue
if (i === 0) color = '#22c55e' // green
else if (i === stops.length - 1 && stops.length > 1) color = '#ef4444' // red
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const indexOffset = hasGpsOrigin ? 1 : 0
const label = String.fromCharCode(65 + Math.min(i, 25))
stops.forEach((stop, i) => {
const displayIndex = i + indexOffset
const effectiveTotal = stops.length + indexOffset
let pinClass = 'navi-pin navi-pin--intermediate'
if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin'
else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination'
const label = String.fromCharCode(65 + Math.min(displayIndex, 25))
const el = document.createElement('div')
el.className = 'navi-marker'
el.style.cssText = `
width: 28px; height: 28px; border-radius: 50%;
background: ${color}; border: 2px solid white;
display: flex; align-items: center; justify-content: center;
color: white; font-size: 12px; font-weight: bold;
cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.4);
`
el.className = pinClass
el.textContent = label
el.addEventListener('click', (e) => {
@ -231,9 +346,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
.setLngLat([stop.lon, stop.lat])
.setHTML(
`<div style="color:#fff;font-size:12px;max-width:200px">
`<div style="font-size:12px;max-width:200px">
<strong>${stop.name}</strong>
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:#dc2626;border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:var(--status-danger);border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
</div>`
)
.addTo(map)
@ -264,7 +379,7 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
}
}
}, [stops, route])
}, [stops, route, gpsOrigin, geoPermission])
return <div ref={mapRef} className="w-full h-full" />
})

View file

@ -1,9 +1,10 @@
import { Car, Footprints, Bike } from 'lucide-react'
import { useStore } from '../store'
const MODES = [
{ id: 'auto', label: 'Drive', icon: '🚗' },
{ id: 'pedestrian', label: 'Walk', icon: '🚶' },
{ id: 'bicycle', label: 'Bike', icon: '🚴' },
{ id: 'auto', label: 'Drive', Icon: Car },
{ id: 'pedestrian', label: 'Walk', Icon: Footprints },
{ id: 'bicycle', label: 'Bike', Icon: Bike },
]
export default function ModeSelector() {
@ -11,23 +12,32 @@ export default function ModeSelector() {
const setMode = useStore((s) => s.setMode)
return (
<div className="flex gap-1" role="radiogroup" aria-label="Travel mode">
{MODES.map((m) => (
<button
key={m.id}
role="radio"
aria-checked={mode === m.id}
onClick={() => setMode(m.id)}
className={`flex-1 py-1.5 px-2 rounded text-xs font-medium transition-colors ${
mode === m.id
? 'bg-cyan-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
<span className="mr-1">{m.icon}</span>
{m.label}
</button>
))}
<div
className="flex rounded-lg overflow-hidden"
style={{ border: '1px solid var(--border)' }}
role="radiogroup"
aria-label="Travel mode"
>
{MODES.map((m) => {
const active = mode === m.id
return (
<button
key={m.id}
role="radio"
aria-checked={active}
onClick={() => setMode(m.id)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-2 text-xs font-medium transition-colors duration-100"
style={{
background: active ? 'var(--accent-muted)' : 'transparent',
color: active ? 'var(--accent)' : 'var(--text-secondary)',
borderRight: m.id !== 'bicycle' ? '1px solid var(--border)' : 'none',
}}
>
<m.Icon size={14} />
{m.label}
</button>
)
})}
</div>
)
}

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>
)}

View file

@ -0,0 +1,236 @@
import { useEffect, useState } from 'react'
import { X, Navigation, Plus, Bookmark, Share2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { useStore } from '../store'
import { fetchElevation } from '../api'
/** Meters to feet */
const M_TO_FT = 3.28084
/** Build display address from raw result data */
function buildAddress(place) {
if (place.address) return place.address
const raw = place.raw || {}
const parts = [raw.street, raw.city, raw.state, raw.postcode].filter(Boolean)
return parts.join(', ') || null
}
export default function PlaceDetail() {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const startDirections = useStore((s) => s.startDirections)
const addStop = useStore((s) => s.addStop)
const stops = useStore((s) => s.stops)
const geoPermission = useStore((s) => s.geoPermission)
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Fetch elevation when place changes
const placeLat = selectedPlace?.lat
const placeLon = selectedPlace?.lon
useEffect(() => {
if (placeLat == null || placeLon == null) return
let cancelled = false
fetchElevation(placeLat, placeLon).then((h) => {
if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h })
})
return () => { cancelled = true }
}, [placeLat, placeLon])
// Derive elevation/loading from comparing result to current place
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
const elevation = !elevLoading ? elevResult.value : null
if (!selectedPlace) return null
const address = buildAddress(selectedPlace)
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
const raw = selectedPlace.raw || {}
// Check if place is already in stops
const existingStopIndex = stops.findIndex(
(s) => Math.abs(s.lat - selectedPlace.lat) < 0.00001 && Math.abs(s.lon - selectedPlace.lon) < 0.00001
)
const handleDirections = () => {
startDirections(selectedPlace)
if (geoPermission !== 'granted' && stops.length === 0) {
toast('Set a starting point to get directions', { icon: '\u{1F4CD}' })
}
}
const handleAddStop = () => {
addStop({
lat: selectedPlace.lat,
lon: selectedPlace.lon,
name: selectedPlace.name,
source: selectedPlace.source,
matchCode: selectedPlace.matchCode,
})
clearSelectedPlace()
}
const handleSave = () => {
toast('Saved places coming soon')
}
const handleShare = () => {
const text = [
selectedPlace.name,
address,
`${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`,
].filter(Boolean).join('\n')
navigator.clipboard.writeText(text).then(
() => toast('Copied to clipboard'),
() => toast.error('Failed to copy')
)
}
const panelContent = (
<>
{/* Close button */}
<button
onClick={clearSelectedPlace}
className="absolute top-3 right-3 p-1 rounded"
style={{ color: 'var(--text-tertiary)' }}
aria-label="Close detail panel"
>
<X size={18} />
</button>
{/* Place name */}
<div className="pr-8">
<h2 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}>
{selectedPlace.name}
</h2>
{selectedPlace.type && (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{selectedPlace.type}
</span>
)}
</div>
{/* Address */}
{address && (
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
{address}
</p>
)}
{/* Coordinates + elevation */}
<div className="mt-3 font-mono text-xs" style={{ color: 'var(--text-secondary)' }}>
<span>{selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)}</span>
<span className="mx-2">&middot;</span>
<span>
{elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'}
</span>
</div>
{/* Optional extras */}
{(raw.opening_hours || raw.website || raw.phone) && (
<div className="mt-3 flex flex-col gap-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{raw.opening_hours && <span>{raw.opening_hours}</span>}
{raw.website && (
<a
href={raw.website}
target="_blank"
rel="noopener noreferrer"
className="underline truncate"
style={{ color: 'var(--accent)' }}
>
{raw.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
)}
{raw.phone && <span>{raw.phone}</span>}
</div>
)}
{/* Action buttons */}
<div className="mt-auto pt-4 flex gap-2">
<button
onClick={handleDirections}
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
>
<Navigation size={13} />
Directions
</button>
{existingStopIndex >= 0 ? (
<span
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
Added as stop {String.fromCharCode(65 + existingStopIndex)}
</span>
) : (
<button
onClick={handleAddStop}
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
>
<Plus size={13} />
Add stop
</button>
)}
<button
onClick={handleSave}
className="p-2 rounded-lg"
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
aria-label="Save place"
>
<Bookmark size={14} />
</button>
<button
onClick={handleShare}
className="p-2 rounded-lg"
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
aria-label="Share place"
>
<Share2 size={14} />
</button>
</div>
</>
)
// Mobile: bottom overlay
if (isMobile) {
return (
<div
className="navi-place-detail navi-place-detail-active fixed bottom-0 left-0 right-0 z-20 p-4 rounded-t-2xl flex flex-col"
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
maxHeight: '50vh',
}}
>
{panelContent}
</div>
)
}
// Desktop: side panel
return (
<div
className="navi-place-detail navi-place-detail-active absolute top-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
left: '20rem',
width: '360px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{panelContent}
</div>
)
}

View file

@ -1,32 +1,59 @@
import { useRef, useEffect, useCallback, useState } from 'react'
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { useStore } from '../store'
import { searchGeocode } from '../api'
export default function SearchBar() {
/** Get category icon based on result type/source */
function CategoryIcon({ result }) {
const type = result.type || ''
const source = result.source || ''
const size = 14
if (source === 'nickname') return <Star size={size} />
if (type === 'coordinates') return <Crosshair size={size} />
if (type === 'locality' || type === 'city') return <Building2 size={size} />
// POI subcategories from osm_value if available
const osmVal = result.raw?.osm_value || ''
if (osmVal.includes('cafe') || osmVal.includes('coffee')) return <Coffee size={size} />
if (osmVal.includes('fuel') || osmVal.includes('gas')) return <Fuel size={size} />
if (osmVal.includes('shop') || osmVal.includes('supermarket')) return <ShoppingBag size={size} />
if (osmVal.includes('hotel') || osmVal.includes('motel')) return <Hotel size={size} />
return <MapPin size={size} />
}
const SearchBar = forwardRef(function SearchBar(_, ref) {
const inputRef = useRef(null)
const [activeIndex, setActiveIndex] = useState(-1)
const debounceRef = useRef(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}))
const query = useStore((s) => s.query)
const results = useStore((s) => s.results)
const searchLoading = useStore((s) => s.searchLoading)
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
const stops = useStore((s) => s.stops)
const pendingDestination = useStore((s) => s.pendingDestination)
const setQuery = useStore((s) => s.setQuery)
const setResults = useStore((s) => s.setResults)
const setSearchLoading = useStore((s) => s.setSearchLoading)
const setAbortController = useStore((s) => s.setAbortController)
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
const addStop = useStore((s) => s.addStop)
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
// Focus on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
const doSearch = useCallback(
async (q) => {
// Abort previous
const prev = useStore.getState().abortController
if (prev) prev.abort()
@ -61,19 +88,40 @@ export default function SearchBar() {
const handleChange = (e) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => doSearch(val), 150)
}
const handleClear = () => {
setQuery('')
setResults([])
setAutocompleteOpen(false)
inputRef.current?.focus()
}
const selectResult = (result) => {
addStop({
lat: result.lat,
lon: result.lon,
name: result.name,
source: result.source,
matchCode: result.match_code,
})
const { pendingDestination: pending } = useStore.getState()
if (pending) {
// GPS-denied Directions flow: this result becomes the starting point
addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
clearPendingDestination()
toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
} else {
// Normal flow: open PlaceDetail
setSelectedPlace({
lat: result.lat,
lon: result.lon,
name: result.name,
address: result.address || null,
type: result.type,
source: result.source,
matchCode: result.match_code,
raw: result.raw || {},
})
}
setQuery('')
setResults([])
setAutocompleteOpen(false)
@ -83,9 +131,7 @@ export default function SearchBar() {
const handleKeyDown = (e) => {
if (!autocompleteOpen || results.length === 0) {
if (e.key === 'Escape') {
setAutocompleteOpen(false)
}
if (e.key === 'Escape') setAutocompleteOpen(false)
return
}
@ -116,35 +162,51 @@ export default function SearchBar() {
return (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
placeholder={atCap ? 'Max 10 stops reached' : 'Search for a place...'}
disabled={atCap}
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-cyan-400 focus:ring-1 focus:ring-cyan-400 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
aria-label="Search places"
aria-expanded={autocompleteOpen}
aria-autocomplete="list"
role="combobox"
/>
{searchLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
<div className="relative">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
disabled={atCap}
className="navi-input w-full pr-8"
aria-label="Search places"
aria-expanded={autocompleteOpen}
aria-autocomplete="list"
role="combobox"
/>
{/* Clear / Loading indicator */}
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
{searchLoading ? (
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
/>
) : query ? (
<button
onClick={handleClear}
className="p-0.5"
style={{ color: 'var(--text-tertiary)' }}
aria-label="Clear search"
>
<X size={14} />
</button>
) : null}
</div>
</div>
{/* Autocomplete dropdown */}
{autocompleteOpen && results.length > 0 && (
<ul
className="absolute z-50 mt-1 w-full bg-gray-800 border border-gray-600 rounded-lg shadow-lg overflow-hidden max-h-72 overflow-y-auto"
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-72 overflow-y-auto"
style={{
background: 'var(--bg-overlay)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
}}
role="listbox"
>
{results.map((r, i) => (
@ -152,27 +214,34 @@ export default function SearchBar() {
key={`${r.lat}-${r.lon}-${i}`}
role="option"
aria-selected={i === activeIndex}
className={`px-3 py-2 cursor-pointer text-sm border-b border-gray-700 last:border-b-0 ${
i === activeIndex
? 'bg-gray-700 text-white'
: 'text-gray-200 hover:bg-gray-700'
}`}
className="px-3 py-2 cursor-pointer text-sm"
style={{
background: i === activeIndex ? 'var(--accent-muted)' : 'transparent',
borderBottom: i < results.length - 1 ? '1px solid var(--border-subtle)' : 'none',
}}
onClick={() => selectResult(r)}
onMouseEnter={() => setActiveIndex(i)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate flex-1">{r.name}</span>
<span className="flex items-center gap-1 shrink-0">
<div className="flex items-center gap-2">
<span className="shrink-0" style={{ color: 'var(--text-tertiary)' }}>
<CategoryIcon result={r} />
</span>
<span className="truncate flex-1" style={{ color: 'var(--text-primary)' }}>
{r.name}
</span>
<span className="flex items-center gap-1.5 shrink-0">
{r.match_code?.housenumber === 'matched' && (
<span className="text-[10px] px-1.5 py-0.5 bg-green-800 text-green-200 rounded font-medium">
exact match
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
exact
</span>
)}
<span className="text-[10px] text-gray-500">{r.source}</span>
</span>
</div>
<div className="text-[11px] text-gray-400 mt-0.5">
{r.type} &middot; {r.confidence}
<div className="text-[11px] mt-0.5 ml-6" style={{ color: 'var(--text-tertiary)' }}>
{r.type}{r.confidence && r.confidence !== 'high' ? ` \u00b7 ${r.confidence}` : ''}
</div>
</li>
))}
@ -180,4 +249,6 @@ export default function SearchBar() {
)}
</div>
)
}
})
export default SearchBar

View file

@ -1,8 +1,9 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { X, GripVertical } from 'lucide-react'
import { useStore } from '../store'
export default function StopItem({ stop, index, total }) {
export default function StopItem({ stop, index, total, indexOffset = 0 }) {
const removeStop = useStore((s) => s.removeStop)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
@ -14,62 +15,62 @@ export default function StopItem({ stop, index, total }) {
opacity: isDragging ? 0.5 : 1,
}
// Pin color logic
let pinColor = 'bg-blue-500' // intermediate
let pinLabel = String(index + 1)
if (index === 0) {
pinColor = 'bg-green-500'
pinLabel = 'A'
} else if (index === total - 1 && total > 1) {
pinColor = 'bg-red-500'
pinLabel = String.fromCharCode(65 + Math.min(index, 25)) // A-Z
} else {
pinLabel = String.fromCharCode(65 + Math.min(index, 25))
}
const displayIndex = index + indexOffset
const effectiveTotal = total + indexOffset
// Pin color from tokens
let pinVar = '--pin-intermediate'
if (displayIndex === 0) pinVar = '--pin-origin'
else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinVar = '--pin-destination'
const pinLabel = String.fromCharCode(65 + Math.min(displayIndex, 25))
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 py-1.5 px-2 bg-gray-800/60 rounded border border-gray-700 group"
style={{
...style,
background: 'var(--bg-overlay)',
border: '1px solid var(--border-subtle)',
}}
className="flex items-center gap-2 py-1.5 px-2 rounded group"
>
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-gray-500 hover:text-gray-300 touch-none"
className="cursor-grab active:cursor-grabbing touch-none"
style={{ color: 'var(--text-tertiary)' }}
aria-label="Drag to reorder"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<circle cx="4" cy="2" r="1" />
<circle cx="8" cy="2" r="1" />
<circle cx="4" cy="6" r="1" />
<circle cx="8" cy="6" r="1" />
<circle cx="4" cy="10" r="1" />
<circle cx="8" cy="10" r="1" />
</svg>
<GripVertical size={14} />
</button>
{/* Pin indicator */}
<span
className={`${pinColor} text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center shrink-0`}
className="text-[10px] font-semibold w-5 h-5 rounded-full flex items-center justify-center shrink-0"
style={{
background: `var(${pinVar})`,
color: '#fff',
border: '1.5px solid var(--pin-stroke)',
}}
>
{pinLabel}
</span>
{/* Stop name */}
<span className="flex-1 text-sm text-gray-200 truncate">{stop.name}</span>
<span className="flex-1 text-sm truncate" style={{ color: 'var(--text-primary)' }}>
{stop.name}
</span>
{/* Remove button */}
<button
onClick={() => removeStop(stop.id)}
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 transition-opacity"
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5"
style={{ color: 'var(--text-tertiary)' }}
aria-label={`Remove stop ${stop.name}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<X size={14} />
</button>
</div>
)

View file

@ -14,11 +14,17 @@ import {
} from '@dnd-kit/sortable'
import { useStore } from '../store'
import StopItem from './StopItem'
import GpsOriginItem from './GpsOriginItem'
export default function StopList() {
const stops = useStore((s) => s.stops)
const reorderStops = useStore((s) => s.reorderStops)
const geoPermission = useStore((s) => s.geoPermission)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const pendingDestination = useStore((s) => s.pendingDestination)
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const indexOffset = hasGpsOrigin ? 1 : 0
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@ -34,25 +40,34 @@ export default function StopList() {
reorderStops(arrayMove(stops, oldIndex, newIndex))
}
if (stops.length === 0) {
if (stops.length === 0 && !hasGpsOrigin) {
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 className="text-xs px-2 py-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
{pendingDestination
? 'Search for a starting point above'
: 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">
<div className="flex flex-col gap-1">
{hasGpsOrigin && <GpsOriginItem />}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
{stops.map((stop, i) => (
<StopItem key={stop.id} stop={stop} index={i} total={stops.length} />
<StopItem
key={stop.id}
stop={stop}
index={i}
total={stops.length}
indexOffset={indexOffset}
/>
))}
</div>
</SortableContext>
</DndContext>
</SortableContext>
</DndContext>
</div>
)
}