diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx
new file mode 100644
index 0000000..d2f7db4
--- /dev/null
+++ b/src/components/DirectionsPanel.jsx
@@ -0,0 +1,263 @@
+import { useEffect } from "react"
+import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react"
+import { useStore } from "../store"
+import LocationInput from "./LocationInput"
+import ManeuverList from "./ManeuverList"
+
+const TRAVEL_MODES = [
+ { id: "auto", label: "Drive", Icon: Car },
+ { id: "foot", label: "Foot", Icon: Footprints },
+ { id: "mtb", label: "MTB", Icon: Bike },
+ { id: "atv", label: "ATV", Icon: Car },
+ { id: "vehicle", label: "4x4", Icon: Car },
+]
+
+const BOUNDARY_MODES = [
+ { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" },
+ { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" },
+ { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" },
+]
+
+export default function DirectionsPanel({ onClose }) {
+ const routeStart = useStore((s) => s.routeStart)
+ const routeEnd = useStore((s) => s.routeEnd)
+ const routeMode = useStore((s) => s.routeMode)
+ const boundaryMode = useStore((s) => s.boundaryMode)
+ const routeResult = useStore((s) => s.routeResult)
+ const routeLoading = useStore((s) => s.routeLoading)
+ const routeError = useStore((s) => s.routeError)
+ const stops = useStore((s) => s.stops)
+ const userLocation = useStore((s) => s.userLocation)
+ const geoPermission = useStore((s) => s.geoPermission)
+
+ const setRouteStart = useStore((s) => s.setRouteStart)
+ const setRouteEnd = useStore((s) => s.setRouteEnd)
+ const setRouteMode = useStore((s) => s.setRouteMode)
+ const setBoundaryMode = useStore((s) => s.setBoundaryMode)
+ const computeRoute = useStore((s) => s.computeRoute)
+ const clearRoute = useStore((s) => s.clearRoute)
+ const setDirectionsMode = useStore((s) => s.setDirectionsMode)
+ const addStop = useStore((s) => s.addStop)
+ const removeStop = useStore((s) => s.removeStop)
+ const reorderStops = useStore((s) => s.reorderStops)
+
+ // Auto-fill origin with GPS if available and origin is empty
+ useEffect(() => {
+ if (!routeStart && geoPermission === "granted" && userLocation) {
+ setRouteStart({
+ lat: userLocation.lat,
+ lon: userLocation.lon,
+ name: "Your location",
+ source: "gps",
+ })
+ }
+ }, [routeStart, geoPermission, userLocation, setRouteStart])
+
+ // Auto-compute route when both endpoints are set
+ useEffect(() => {
+ if (routeStart && routeEnd) {
+ computeRoute()
+ }
+ }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon])
+
+ const handleSwap = () => {
+ const tempStart = routeStart
+ const tempEnd = routeEnd
+ setRouteStart(tempEnd)
+ setRouteEnd(tempStart)
+ }
+
+ const handleClose = () => {
+ clearRoute()
+ setDirectionsMode(false)
+ onClose?.()
+ }
+
+ const handleAddStop = () => {
+ // Insert a stop between origin and destination
+ // For now, this adds to the stops array
+ // The UI will show intermediate stops
+ }
+
+ // Multi-stop support: show intermediate stops from the stops array
+ const intermediateStops = stops.slice(1, -1) // Everything except first and last
+
+ return (
+
+ {/* Header */}
+
+
+ Directions
+
+
+
+
+ {/* Origin/Destination inputs with swap button */}
+
+ {/* Origin */}
+
+
+ {/* Swap button - positioned between inputs */}
+
+
+ {/* Intermediate stops (for multi-stop routes) */}
+ {intermediateStops.map((stop, idx) => (
+
+ {
+ if (place) {
+ const newStops = [...stops]
+ newStops[idx + 1] = { ...newStops[idx + 1], ...place }
+ reorderStops(newStops)
+ } else {
+ removeStop(stop.id)
+ }
+ }}
+ placeholder="Stop"
+ icon="stop"
+ fieldId={`stop-${idx}`}
+ />
+
+ ))}
+
+ {/* Destination */}
+
+
+ {/* Add stop button */}
+ {routeStart && routeEnd && stops.length < 10 && (
+
+ )}
+
+
+ {/* Travel mode selector */}
+
+ {TRAVEL_MODES.map((m) => {
+ const active = routeMode === m.id
+ return (
+
+ )
+ })}
+
+
+ {/* Boundary mode selector (only for non-auto modes) */}
+ {routeMode !== "auto" && (
+
+ {BOUNDARY_MODES.map((m) => {
+ const active = boundaryMode === m.id
+ return (
+
+ )
+ })}
+
+ )}
+
+ {/* Loading indicator */}
+ {routeLoading && (
+
+
+
+ Finding route...
+
+
+ )}
+
+ {/* Error message */}
+ {routeError && (
+
+ {routeError}
+
+ )}
+
+ {/* Route summary and maneuvers */}
+ {routeResult && !routeLoading && (
+
+
+
+ )}
+
+ {/* Hint when waiting for input */}
+ {!routeStart && !routeEnd && !routeLoading && (
+
+
+ Enter addresses, paste coordinates, or click the map
+
+
+ )}
+
+ )
+}
diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx
new file mode 100644
index 0000000..a15b1bb
--- /dev/null
+++ b/src/components/LocationInput.jsx
@@ -0,0 +1,301 @@
+import { useRef, useEffect, useCallback, useState } from "react"
+import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2 } from "lucide-react"
+import { useStore } from "../store"
+import { searchGeocode } from "../api"
+import { buildAddress } from "../utils/place"
+import { hasFeature } from "../config"
+
+/** Parse coordinate input like "42.35, -114.30" */
+function parseCoordinates(input) {
+ if (!input) return null
+ const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/
+ const match = input.trim().match(pattern)
+ if (!match) return null
+ const lat = parseFloat(match[1])
+ const lon = parseFloat(match[2])
+ if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null
+ return { lat, lon }
+}
+
+function CategoryIcon({ result, size = 14 }) {
+ const type = result.type || ""
+ const source = result.source || ""
+ if (result._isContact) return
+ if (source === "nickname") return
+ if (type === "coordinates") return
+ if (type === "locality" || type === "city") return
+ const osmVal = result.raw?.osm_value || ""
+ if (osmVal.includes("cafe") || osmVal.includes("coffee")) return
+ if (osmVal.includes("fuel") || osmVal.includes("gas")) return
+ if (osmVal.includes("shop") || osmVal.includes("supermarket")) return
+ if (osmVal.includes("hotel") || osmVal.includes("motel")) return
+ return
+}
+
+export default function LocationInput({
+ value, // { lat, lon, name } or null
+ onChange, // (place) => void
+ placeholder,
+ icon, // "origin" | "destination" | "stop"
+ fieldId, // unique id for this field (for map click targeting)
+ onFocus, // () => void
+ autoFocus,
+}) {
+ const inputRef = useRef(null)
+ const [query, setQuery] = useState(value?.name || "")
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [open, setOpen] = useState(false)
+ const [activeIndex, setActiveIndex] = useState(-1)
+ const debounceRef = useRef(null)
+ const abortRef = useRef(null)
+
+ const contacts = useStore((s) => s.contacts)
+ const activeDirectionsField = useStore((s) => s.activeDirectionsField)
+ const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField)
+
+ // Sync display value when external value changes
+ useEffect(() => {
+ if (value?.name && value.name !== query) {
+ setQuery(value.name)
+ } else if (!value && query && !open) {
+ // Value cleared externally
+ setQuery("")
+ }
+ }, [value?.name, value?.lat, value?.lon])
+
+ const doSearch = useCallback(async (q) => {
+ if (abortRef.current) abortRef.current.abort()
+
+ if (!q.trim()) {
+ setResults([])
+ setOpen(false)
+ setLoading(false)
+ return
+ }
+
+ // Check coordinates first
+ const coords = parseCoordinates(q)
+ if (coords) {
+ const coordResult = {
+ lat: coords.lat,
+ lon: coords.lon,
+ name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5),
+ address: "Coordinates",
+ type: "coordinates",
+ source: "coordinates",
+ match_code: null,
+ raw: {},
+ }
+ setResults([coordResult])
+ setOpen(true)
+ setLoading(false)
+ return
+ }
+
+ // Contact matches
+ let contactResults = []
+ if (hasFeature("has_contacts") && contacts.length > 0) {
+ const lower = q.trim().toLowerCase()
+ contactResults = contacts
+ .filter((c) =>
+ (c.label || "").toLowerCase().startsWith(lower) ||
+ (c.name || "").toLowerCase().startsWith(lower) ||
+ (c.call_sign || "").toLowerCase().startsWith(lower)
+ )
+ .slice(0, 3)
+ .map((c) => ({
+ lat: c.lat,
+ lon: c.lon,
+ name: c.label,
+ address: c.address || c.name || "",
+ type: "contact",
+ source: "contacts",
+ match_code: null,
+ raw: { contact: c },
+ _isContact: true,
+ }))
+ }
+
+ const ctrl = new AbortController()
+ abortRef.current = ctrl
+ setLoading(true)
+
+ try {
+ const data = await searchGeocode(q.trim(), 5, ctrl.signal)
+ const combined = [...contactResults, ...(data.results || [])]
+ setResults(combined)
+ setOpen(combined.length > 0)
+ setActiveIndex(-1)
+ } catch (e) {
+ if (e.name !== "AbortError") {
+ if (contactResults.length > 0) {
+ setResults(contactResults)
+ setOpen(true)
+ } else {
+ setResults([])
+ setOpen(false)
+ }
+ }
+ } finally {
+ setLoading(false)
+ }
+ }, [contacts])
+
+ 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([])
+ setOpen(false)
+ onChange(null)
+ inputRef.current?.focus()
+ }
+
+ const selectResult = (result) => {
+ onChange({
+ lat: result.lat,
+ lon: result.lon,
+ name: result.name,
+ source: result.source,
+ matchCode: result.match_code,
+ })
+ setQuery(result.name)
+ setResults([])
+ setOpen(false)
+ setActiveIndex(-1)
+ }
+
+ const handleKeyDown = (e) => {
+ if (!open || results.length === 0) {
+ if (e.key === "Escape") setOpen(false)
+ return
+ }
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault()
+ setActiveIndex((prev) => Math.min(prev + 1, results.length - 1))
+ break
+ case "ArrowUp":
+ e.preventDefault()
+ setActiveIndex((prev) => Math.max(prev - 1, -1))
+ break
+ case "Enter":
+ e.preventDefault()
+ if (activeIndex >= 0 && activeIndex < results.length) {
+ selectResult(results[activeIndex])
+ }
+ break
+ case "Escape":
+ e.preventDefault()
+ setOpen(false)
+ setActiveIndex(-1)
+ break
+ }
+ }
+
+ const handleFocus = () => {
+ setActiveDirectionsField(fieldId)
+ if (results.length > 0) setOpen(true)
+ onFocus?.()
+ }
+
+ const handleBlur = () => {
+ // Delay to allow click on dropdown
+ setTimeout(() => setOpen(false), 150)
+ }
+
+ const isActive = activeDirectionsField === fieldId
+
+ const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)"
+
+ return (
+
+
+ {icon === "origin" ? (
+
+ ) : (
+
+ )}
+
+ {loading ? (
+
+ ) : query ? (
+
+ ) : null}
+
+
+ {open && results.length > 0 && (
+
+ {results.map((r, i) => {
+ const isPoi = r.type === "poi" && r.raw?.name
+ const isContact = r._isContact
+ const primary = isContact ? r.name : isPoi ? r.raw.name : r.name
+ const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null
+ return (
+ - selectResult(r)}
+ onMouseEnter={() => setActiveIndex(i)}
+ >
+
+
+
+
+
+ {primary}
+
+
+ {secondary && (
+
+ {secondary}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx
index ca9b5c5..a46eac5 100644
--- a/src/components/MapView.jsx
+++ b/src/components/MapView.jsx
@@ -1441,6 +1441,8 @@ const MapView = forwardRef(function MapView(_, ref) {
const pickingLocationFor = useStore((s) => s.pickingLocationFor)
const setEditingContact = useStore((s) => s.setEditingContact)
const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor)
+ const directionsMode = useStore((s) => s.directionsMode)
+ const activeDirectionsField = useStore((s) => s.activeDirectionsField)
// Zoom level indicator state
const [zoomLevel, setZoomLevel] = useState(10)
@@ -1999,7 +2001,37 @@ const MapView = forwardRef(function MapView(_, ref) {
return
}
-
+ // Handle directions mode — click fills the active field
+ const { directionsMode, activeDirectionsField, setRouteStart, setRouteEnd, setActiveDirectionsField } = useStore.getState()
+ if (directionsMode && activeDirectionsField) {
+ const { lng, lat } = e.lngLat
+ // Reverse geocode for name
+ fetchReverse(lat, lng).then((place) => {
+ const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5)
+ const location = { lat, lon: lng, name, source: "map_click" }
+ if (activeDirectionsField === "origin") {
+ setRouteStart(location)
+ setActiveDirectionsField("destination")
+ } else if (activeDirectionsField === "destination") {
+ setRouteEnd(location)
+ setActiveDirectionsField(null)
+ } else if (activeDirectionsField.startsWith("stop-")) {
+ // Handle intermediate stops - would need more logic
+ setActiveDirectionsField(null)
+ }
+ }).catch(() => {
+ const name = lat.toFixed(5) + ", " + lng.toFixed(5)
+ const location = { lat, lon: lng, name, source: "map_click" }
+ if (activeDirectionsField === "origin") {
+ setRouteStart(location)
+ setActiveDirectionsField("destination")
+ } else if (activeDirectionsField === "destination") {
+ setRouteEnd(location)
+ setActiveDirectionsField(null)
+ }
+ })
+ return
+ }
const store = useStore.getState()
const marker = store.clickMarker
@@ -2694,6 +2726,22 @@ const MapView = forwardRef(function MapView(_, ref) {
}
}, [pickingLocationFor])
+ // Handle directions mode cursor
+ useEffect(() => {
+ const map = mapInstance.current
+ if (!map) return
+ if (directionsMode && activeDirectionsField) {
+ map.getCanvas().style.cursor = 'crosshair'
+ } else if (!measuringRef.current.active && !pickingLocationFor) {
+ map.getCanvas().style.cursor = ''
+ }
+ return () => {
+ if (map && !measuringRef.current.active && !pickingLocationFor) {
+ map.getCanvas().style.cursor = ''
+ }
+ }
+ }, [directionsMode, activeDirectionsField])
+
// ESC key handler for location pick mode
useEffect(() => {
const handleKeyDown = (e) => {
diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx
index efc9b5a..a708734 100644
--- a/src/components/Panel.jsx
+++ b/src/components/Panel.jsx
@@ -7,6 +7,7 @@ import SearchBar from './SearchBar'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
+import DirectionsPanel from './DirectionsPanel'
const TRAVEL_MODES = [
{ id: 'auto', label: 'Drive', Icon: Car },
@@ -39,6 +40,8 @@ export default function Panel({ onClearRoute }) {
const activeTab = useStore((s) => s.activeTab)
const auth = useStore((s) => s.auth)
const setActiveTab = useStore((s) => s.setActiveTab)
+ const directionsMode = useStore((s) => s.directionsMode)
+ const setDirectionsMode = useStore((s) => s.setDirectionsMode)
const panelState = usePanelState()
@@ -86,7 +89,12 @@ export default function Panel({ onClearRoute }) {
const showRouteSection = hasRoutePoints || routeResult || routeLoading
const showEmptyState = panelState === 'IDLE' && !hasRoutePoints
- const routesContent = (
+ const routesContent = directionsMode ? (
+ {
+ setDirectionsMode(false)
+ onClearRoute?.()
+ }} />
+ ) : (
<>
diff --git a/src/store.js b/src/store.js
index a4039dc..4ea2839 100644
--- a/src/store.js
+++ b/src/store.js
@@ -173,23 +173,38 @@ export const useStore = create((set, get) => ({
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
- // Master startDirections - restored verbatim
+ // Master startDirections - enters directions mode with destination pre-filled
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 {
- // GPS denied, no stops: set pendingDestination only; origin-picker will add both
- set({ pendingDestination: place, selectedPlace: null })
+ const { geoPermission, userLocation, clearRoute } = get()
+ clearRoute()
+
+ // Set destination from the clicked place
+ const destination = {
+ lat: place.lat,
+ lon: place.lon,
+ name: place.name,
+ source: place.source,
+ matchCode: place.matchCode,
}
+
+ // Set origin from GPS if available
+ let origin = null
+ if (geoPermission === 'granted' && userLocation) {
+ origin = {
+ lat: userLocation.lat,
+ lon: userLocation.lon,
+ name: 'Your location',
+ source: 'gps',
+ }
+ }
+
+ set({
+ routeEnd: destination,
+ routeStart: origin,
+ directionsMode: true,
+ activeDirectionsField: origin ? null : 'origin', // Focus origin if empty
+ selectedPlace: null,
+ })
},
// Legacy route setter (for 3+ stop Valhalla optimization)
@@ -213,6 +228,8 @@ export const useStore = create((set, get) => ({
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
+ directionsMode: false, // true when directions panel is active
+ activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting)
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid'
@@ -224,6 +241,8 @@ export const useStore = create((set, get) => ({
},
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
+ setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }),
+ setActiveDirectionsField: (field) => set({ activeDirectionsField: field }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })