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

@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>Navi</title>
</head>
<body>

38
package-lock.json generated
View file

@ -11,11 +11,13 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"lucide-react": "^1.8.0",
"maplibre-gl": "^5.23.0",
"pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
"zustand": "^5.0.12"
},
"devDependencies": {
@ -1668,7 +1670,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@ -2107,6 +2108,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -2615,6 +2625,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -2953,6 +2972,23 @@
"react": "^19.2.5"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",

View file

@ -13,11 +13,13 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"lucide-react": "^1.8.0",
"maplibre-gl": "^5.23.0",
"pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
"zustand": "^5.0.12"
},
"devDependencies": {

View file

@ -1,17 +1,24 @@
import { useEffect, useRef, useCallback } from 'react'
import { useStore } from './store'
import { useTheme } from './hooks/useTheme'
import { requestRoute } from './api'
import { decodePolyline } from './utils/decode'
import MapView from './components/MapView'
import Panel from './components/Panel'
import PlaceDetail from './components/PlaceDetail'
export default function App() {
const mapViewRef = useRef(null)
const routeDebounceRef = useRef(null)
// Initialize theme system
useTheme()
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const setRoute = useStore((s) => s.setRoute)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const setRouteError = useStore((s) => s.setRouteError)
@ -19,10 +26,8 @@ export default function App() {
const setUserLocation = useStore((s) => s.setUserLocation)
const setGeoPermission = useStore((s) => s.setGeoPermission)
// Request geolocation on first route action (2+ stops)
const requestGeo = useCallback(() => {
const { geoPermission } = useStore.getState()
if (geoPermission !== 'prompt') return
// Proactive geolocation request on mount
useEffect(() => {
if (!navigator.geolocation) {
setGeoPermission('denied')
return
@ -33,28 +38,33 @@ export default function App() {
setGeoPermission('granted')
},
() => setGeoPermission('denied'),
{ enableHighAccuracy: true, timeout: 10000 }
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
)
}, [setUserLocation, setGeoPermission])
// Fetch route when stops or mode change (debounced 500ms)
// Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms)
// NOTE: userLocation is NOT a dep read from store inside the callback to avoid re-routing on every GPS update
useEffect(() => {
if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
if (stops.length < 2) {
clearRoute()
return
}
routeDebounceRef.current = setTimeout(async () => {
// Try to get geolocation for potential use
requestGeo()
const { userLocation } = useStore.getState()
// Build effective stop list
let effective = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (gpsOrigin && geoPermission === 'granted' && userLocation) {
effective = [{ lat: userLocation.lat, lon: userLocation.lon }, ...effective]
}
if (effective.length < 2) {
clearRoute()
return
}
const locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
setRouteLoading(true)
try {
const data = await requestRoute(locations, mode)
const data = await requestRoute(effective, mode)
if (data.trip) {
setRoute(data.trip)
} else {
@ -70,7 +80,7 @@ export default function App() {
return () => {
if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
}
}, [stops, mode, clearRoute, setRoute, setRouteLoading, setRouteError, requestGeo])
}, [stops, mode, gpsOrigin, geoPermission, clearRoute, setRoute, setRouteLoading, setRouteError])
// Handle maneuver click fly to that point on the map
const handleManeuverClick = useCallback(
@ -92,9 +102,10 @@ export default function App() {
)
return (
<div className="relative w-screen h-screen overflow-hidden">
<div className="relative w-screen h-screen overflow-hidden" style={{ background: 'var(--bg-base)' }}>
<MapView ref={mapViewRef} />
<Panel onManeuverClick={handleManeuverClick} />
<PlaceDetail />
</div>
)
}

View file

@ -1,6 +1,7 @@
const GEOCODE_URL = '/api/geocode'
const VALHALLA_URL = '/valhalla/route'
const VALHALLA_OPTIMIZED_URL = '/valhalla/optimized_route'
const VALHALLA_HEIGHT_URL = '/valhalla/height'
/**
* Search geocode API with abort support.
@ -87,3 +88,25 @@ export async function requestOptimizedRoute(locations, costing = 'auto') {
clearTimeout(timeout)
}
}
/**
* Fetch elevation for a point via Valhalla height API.
* @param {number} lat
* @param {number} lon
* @returns {Promise<number|null>} Height in meters, or null on error
*/
export async function fetchElevation(lat, lon) {
try {
const resp = await fetch(VALHALLA_HEIGHT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shape: [{ lat, lon }], resample_distance: 100 }),
})
if (!resp.ok) return null
const data = await resp.json()
if (data.height && data.height.length > 0) return data.height[0]
return null
} catch {
return null
}
}

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

45
src/hooks/useTheme.js Normal file
View file

@ -0,0 +1,45 @@
import { useEffect } from 'react'
import { useStore } from '../store'
/**
* Initializes and manages the theme system.
* Call once in App it handles:
* - Reading localStorage override on mount
* - Listening to system prefers-color-scheme
* - Applying data-theme to <html>
* - Updating store.theme (resolved value)
*/
export function useTheme() {
const setTheme = useStore((s) => s.setTheme)
const themeOverride = useStore((s) => s.themeOverride)
// Initialize override from localStorage on first mount
useEffect(() => {
const stored = localStorage.getItem('navi-theme-override')
if (stored === 'dark' || stored === 'light') {
useStore.getState().setThemeOverride(stored)
}
}, [])
// Resolve and apply theme
useEffect(() => {
function resolve() {
if (themeOverride) return themeOverride
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function apply() {
const resolved = resolve()
document.documentElement.setAttribute('data-theme', resolved)
setTheme(resolved)
}
apply()
// Listen for system changes (only matters when no override)
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => { if (!themeOverride) apply() }
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [themeOverride, setTheme])
}

View file

@ -1,38 +1,158 @@
@import "tailwindcss";
/*
NAVI DESIGN TOKENS
Warm grays, sage greens, khaki tans, deep blacks.
No blue in UI chrome.
*/
:root {
/* ── Typography ── */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* ── Type scale ── */
--text-xs: 0.6875rem; /* 11px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.875rem; /* 14px */
--text-md: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
}
/* ═══ DARK MODE (default) ═══ */
[data-theme="dark"] {
--bg-base: #1c1917; /* warm off-black (was #0f1210) */
--bg-raised: #252220; /* raised surface (was #181d1a) */
--bg-overlay: #2e2a27; /* overlay/dropdown (was #1e2522) */
--bg-input: #201d1a; /* input fields (was #141a16) */
--text-primary: #dde3dc;
--text-secondary: #8f9a8e;
--text-tertiary: #5e6b5d;
--text-inverse: #1c1917;
--border: #3a3530; /* warm brown-gray (was #2a3329) */
--border-subtle: #2a2624; /* (was #1f261e) */
--accent: #7a9a6b; /* sage green — interactive states */
--accent-hover: #8fad7f;
--accent-muted: #3d4d36;
--tan: #b8a88a; /* khaki — secondary highlights */
--tan-muted: #4a4235;
--pin-origin: #6b8f5e; /* sage */
--pin-destination: #a67c52; /* rust/tan */
--pin-intermediate: #6b7268; /* warm gray */
--pin-stroke: #1c1917;
--status-success: #6b8f5e;
--status-warning: #b89a4a;
--status-danger: #a65c52;
--route-line: #7a9a6b;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
}
/* ═══ LIGHT MODE ═══ */
[data-theme="light"] {
--bg-base: #ece8e1; /* warm tan-gray (was #f5f2ed) */
--bg-raised: #f5f2ec; /* raised surface (was #ffffff) */
--bg-overlay: #f0ece5; /* overlay/dropdown (was #faf8f5) */
--bg-input: #f5f2ec; /* input fields (was #ffffff) */
--text-primary: #1a1d1a;
--text-secondary: #5c6558;
--text-tertiary: #8a9486;
--text-inverse: #f5f2ed;
--border: #d4cfc5;
--border-subtle: #e8e3db;
--accent: #4a7040;
--accent-hover: #3d5e35;
--accent-muted: #dce8d6;
--tan: #8a7556;
--tan-muted: #f0e8d8;
--pin-origin: #4a7040;
--pin-destination: #8a5c35;
--pin-intermediate: #6b6960;
--pin-stroke: #1a1d1a;
--status-success: #4a7040;
--status-warning: #8a7040;
--status-danger: #8a4040;
--route-line: #4a7040;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
}
/* ═══ BASE STYLES ═══ */
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
}
/* MapLibre popup styling to match dark theme */
body {
background: var(--bg-base);
color: var(--text-primary);
}
/* Mono class utility */
.font-mono {
font-family: var(--font-mono);
}
/* ═══ FOCUS RING — accent, never blue ═══ */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ═══ TRANSITIONS — respect reduced motion ═══ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ═══ MAPLIBRE POPUP ═══ */
.maplibregl-popup-content {
background: #1f2937 !important;
border: 1px solid #374151 !important;
background: var(--bg-raised) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
padding: 8px 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important;
box-shadow: var(--shadow-lg) !important;
color: var(--text-primary) !important;
}
.maplibregl-popup-tip {
border-top-color: #1f2937 !important;
border-bottom-color: #1f2937 !important;
border-top-color: var(--bg-raised) !important;
border-bottom-color: var(--bg-raised) !important;
}
.maplibregl-popup-close-button {
color: #9ca3af !important;
color: var(--text-secondary) !important;
font-size: 16px !important;
padding: 2px 6px !important;
}
.maplibregl-popup-close-button:hover {
color: #fff !important;
color: var(--text-primary) !important;
background: transparent !important;
}
/* Custom scrollbar for panels */
/* ═══ SCROLLBAR ═══ */
::-webkit-scrollbar {
width: 6px;
}
@ -42,10 +162,123 @@ html, body, #root {
}
::-webkit-scrollbar-thumb {
background: #4b5563;
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
background: var(--text-tertiary);
}
/* ═══ GPS CHEVRON MARKER ═══ */
.navi-chevron {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
}
.navi-gps-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--bg-raised);
box-shadow: 0 0 0 2px var(--accent);
}
/* ═══ STOP PIN MARKERS (map) ═══ */
.navi-pin {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
color: #fff;
border: 2px solid var(--pin-stroke);
cursor: pointer;
box-shadow: var(--shadow);
}
.navi-pin--origin { background: var(--pin-origin); }
.navi-pin--destination { background: var(--pin-destination); }
.navi-pin--intermediate { background: var(--pin-intermediate); }
/* ═══ FORM ELEMENTS ═══ */
.navi-input {
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
font-family: var(--font-sans);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-primary);
transition: border-color 0.1s;
}
.navi-input::placeholder {
color: var(--text-tertiary);
}
.navi-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
}
.navi-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.navi-btn-secondary {
padding: 0.375rem 0.75rem;
font-size: var(--text-xs);
font-family: var(--font-sans);
font-weight: 500;
background: var(--tan-muted);
color: var(--tan);
border: 1px solid var(--border);
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.1s;
}
.navi-btn-secondary:hover:not(:disabled) {
background: var(--accent-muted);
color: var(--accent);
}
.navi-btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ═══ PREVIEW PIN (selected but not committed) ═══ */
.navi-pin-preview {
width: 28px;
height: 28px;
border-radius: 50%;
border: 3px solid var(--accent);
background: transparent;
box-shadow: 0 0 0 3px var(--accent-muted), var(--shadow);
pointer-events: none;
}
/* ═══ PLACE DETAIL PANEL ═══ */
.navi-place-detail {
transition: transform 150ms ease, opacity 150ms ease;
}
.navi-place-detail-enter {
transform: translateX(-10px);
opacity: 0;
}
.navi-place-detail-active {
transform: translateX(0);
opacity: 1;
}

View file

@ -1,10 +1,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Toaster } from 'react-hot-toast'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
<Toaster
position="bottom-center"
toastOptions={{
style: {
background: 'var(--bg-overlay)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
fontSize: 'var(--text-sm)',
fontFamily: 'var(--font-sans)',
},
}}
/>
</StrictMode>,
)

View file

@ -54,12 +54,51 @@ export const useStore = create((set, get) => ({
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }),
clearSelectedPlace: () => set({ selectedPlace: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
startDirections: (place) => {
const { geoPermission, stops, addStop, clearStops } = get()
if (geoPermission === 'granted') {
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ gpsOrigin: true, selectedPlace: null })
} else if (stops.length > 0) {
const origin = stops[0]
clearStops()
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
localStorage.removeItem('navi-theme-override')
}
},
}))