From 02f2b25db32966da681ce810780fc68ce6f4df05 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 20 Apr 2026 20:59:18 +0000 Subject: [PATCH] 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 --- index.html | 3 + package-lock.json | 38 ++++- package.json | 2 + src/App.jsx | 45 +++--- src/api.js | 23 +++ src/components/GpsOriginItem.jsx | 33 ++++ src/components/ManeuverList.jsx | 102 +++++++------ src/components/MapView.jsx | 239 +++++++++++++++++++++-------- src/components/ModeSelector.jsx | 50 +++--- src/components/Panel.jsx | 83 +++++++--- src/components/PlaceDetail.jsx | 236 ++++++++++++++++++++++++++++ src/components/SearchBar.jsx | 175 ++++++++++++++------- src/components/StopItem.jsx | 63 ++++---- src/components/StopList.jsx | 39 +++-- src/hooks/useTheme.js | 45 ++++++ src/index.css | 255 +++++++++++++++++++++++++++++-- src/main.jsx | 13 ++ src/store.js | 39 +++++ 18 files changed, 1208 insertions(+), 275 deletions(-) create mode 100644 src/components/GpsOriginItem.jsx create mode 100644 src/components/PlaceDetail.jsx create mode 100644 src/hooks/useTheme.js diff --git a/index.html b/index.html index 80e72d5..51c7f38 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ + + + Navi diff --git a/package-lock.json b/package-lock.json index d470af6..fd4e1c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8b2b551..b2ae015 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/App.jsx b/src/App.jsx index ba7cadd..8bfad1a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ( -
+
+
) } diff --git a/src/api.js b/src/api.js index 7da9c0a..a0e1603 100644 --- a/src/api.js +++ b/src/api.js @@ -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} 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 + } +} diff --git a/src/components/GpsOriginItem.jsx b/src/components/GpsOriginItem.jsx new file mode 100644 index 0000000..13d47f7 --- /dev/null +++ b/src/components/GpsOriginItem.jsx @@ -0,0 +1,33 @@ +/** Non-draggable "Your location" row at top of StopList when GPS is granted. */ +export default function GpsOriginItem() { + return ( +
+ {/* Spacer matching drag handle width */} + + + {/* ATAK chevron icon */} + + + + + + + {/* Label */} + + Your location + +
+ ) +} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx index 11636cb..d869b66 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -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 + case 1: return + case 2: return + case 3: return + case 4: case 5: return + case 6: return + case 7: return + case 8: return + case 9: return + case 10: case 11: case 12: return + case 15: case 16: return + case 24: return + case 25: return + case 26: return + default: return } } @@ -48,15 +48,27 @@ export default function ManeuverList({ onManeuverClick }) { if (routeLoading) { return (
-
- Calculating route... +
+ + Calculating route... +
) } if (routeError) { return ( -
+
{routeError}
) @@ -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 (
{/* Route summary */} -
- +
+ {formatDist(totalDist)} - + {formatTime(totalTime)}
{/* Maneuver steps */} -
+
{allManeuvers.map((man, i) => ( +
` ) .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
}) diff --git a/src/components/ModeSelector.jsx b/src/components/ModeSelector.jsx index c4501cf..0502f99 100644 --- a/src/components/ModeSelector.jsx +++ b/src/components/ModeSelector.jsx @@ -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 ( -
- {MODES.map((m) => ( - - ))} +
+ {MODES.map((m) => { + const active = mode === m.id + return ( + + ) + })}
) } diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 3b56e4e..9b8f311 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -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 = ( <> - {/* Stop list */}
- {/* Mode selector + optimize */} {stops.length >= 1 && (
@@ -117,7 +131,7 @@ export default function Panel({ onManeuverClick }) { @@ -125,28 +139,46 @@ export default function Panel({ onManeuverClick }) {
)} - {/* Maneuver list */} {(route || routeLoading || routeError) && (
)} - {/* TODO: Recents / saved places placeholder */} {stops.length === 0 && !route && ( -
- {/* TODO: Wire recents + favorites in a later phase */} -

Recent places will appear here

+
+

Search and add stops to build your route

)} ) + const header = ( +
+

Navi

+ +
+ ) + // Desktop: side panel if (!isMobile) { return ( -
-

Navi

+
+ {header} {content}
) @@ -162,7 +194,11 @@ export default function Panel({ onManeuverClick }) { return (
{/* Drag handle */}
-
+
{sheetState !== 'collapsed' && (
+ {header} {content}
)} diff --git a/src/components/PlaceDetail.jsx b/src/components/PlaceDetail.jsx new file mode 100644 index 0000000..f338bad --- /dev/null +++ b/src/components/PlaceDetail.jsx @@ -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 */} + + + {/* Place name */} +
+

+ {selectedPlace.name} +

+ {selectedPlace.type && ( + + {selectedPlace.type} + + )} +
+ + {/* Address */} + {address && ( +

+ {address} +

+ )} + + {/* Coordinates + elevation */} +
+ {selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)} + · + + {elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'} + +
+ + {/* Optional extras */} + {(raw.opening_hours || raw.website || raw.phone) && ( +
+ {raw.opening_hours && {raw.opening_hours}} + {raw.website && ( + + {raw.website.replace(/^https?:\/\//, '').replace(/\/$/, '')} + + )} + {raw.phone && {raw.phone}} +
+ )} + + {/* Action buttons */} +
+ + + {existingStopIndex >= 0 ? ( + + Added as stop {String.fromCharCode(65 + existingStopIndex)} + + ) : ( + + )} + + + + +
+ + ) + + // Mobile: bottom overlay + if (isMobile) { + return ( +
+ {panelContent} +
+ ) + } + + // Desktop: side panel + return ( +
+ {panelContent} +
+ ) +} diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index 6923d93..652b216 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -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 + if (type === 'coordinates') return + if (type === 'locality' || type === 'city') return + + // POI subcategories from osm_value if available + 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 +} + +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 (
-
-
- 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 && ( -
-
-
- )} +
+ 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 */} +
+ {searchLoading ? ( +
+ ) : query ? ( + + ) : null}
{/* Autocomplete dropdown */} {autocompleteOpen && results.length > 0 && (
    {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)} > -
    - {r.name} - +
    + + + + + {r.name} + + {r.match_code?.housenumber === 'matched' && ( - - exact match + + exact )} - {r.source}
    -
    - {r.type} · {r.confidence} +
    + {r.type}{r.confidence && r.confidence !== 'high' ? ` \u00b7 ${r.confidence}` : ''}
    ))} @@ -180,4 +249,6 @@ export default function SearchBar() { )}
    ) -} +}) + +export default SearchBar diff --git a/src/components/StopItem.jsx b/src/components/StopItem.jsx index a433e94..f59c93d 100644 --- a/src/components/StopItem.jsx +++ b/src/components/StopItem.jsx @@ -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 (
    {/* Drag handle */} {/* Pin indicator */} {pinLabel} {/* Stop name */} - {stop.name} + + {stop.name} + {/* Remove button */}
    ) diff --git a/src/components/StopList.jsx b/src/components/StopList.jsx index a6b0c6e..a370b09 100644 --- a/src/components/StopList.jsx +++ b/src/components/StopList.jsx @@ -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 ( -
    - {geoPermission === 'denied' - ? 'Add a starting point and destination above' - : 'Search and add stops to build your route'} +
    + {pendingDestination + ? 'Search for a starting point above' + : geoPermission === 'denied' + ? 'Add a starting point and destination above' + : 'Search and add stops to build your route'}
    ) } return ( - - s.id)} strategy={verticalListSortingStrategy}> -
    +
    + {hasGpsOrigin && } + + s.id)} strategy={verticalListSortingStrategy}> {stops.map((stop, i) => ( - + ))} -
    - - + + +
    ) } diff --git a/src/hooks/useTheme.js b/src/hooks/useTheme.js new file mode 100644 index 0000000..f09d835 --- /dev/null +++ b/src/hooks/useTheme.js @@ -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 + * - 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]) +} diff --git a/src/index.css b/src/index.css index e8999cf..671ac29 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/main.jsx b/src/main.jsx index b9a1a6d..ef272ce 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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( + , ) diff --git a/src/store.js b/src/store.js index 4769a6b..3ea3733 100644 --- a/src/store.js +++ b/src/store.js @@ -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') + } + }, }))