mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
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:
parent
6983e2655b
commit
02f2b25db3
18 changed files with 1207 additions and 274 deletions
|
|
@ -4,6 +4,9 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>Navi</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
38
package-lock.json
generated
38
package-lock.json
generated
|
|
@ -11,11 +11,13 @@
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
"maplibre-gl": "^5.23.0",
|
"maplibre-gl": "^5.23.0",
|
||||||
"pmtiles": "^4.4.1",
|
"pmtiles": "^4.4.1",
|
||||||
"protomaps-themes-base": "^4.5.0",
|
"protomaps-themes-base": "^4.5.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1668,7 +1670,6 @@
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
|
|
@ -2107,6 +2108,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
|
@ -2615,6 +2625,15 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|
@ -2953,6 +2972,23 @@
|
||||||
"react": "^19.2.5"
|
"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": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,13 @@
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
"maplibre-gl": "^5.23.0",
|
"maplibre-gl": "^5.23.0",
|
||||||
"pmtiles": "^4.4.1",
|
"pmtiles": "^4.4.1",
|
||||||
"protomaps-themes-base": "^4.5.0",
|
"protomaps-themes-base": "^4.5.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
41
src/App.jsx
41
src/App.jsx
|
|
@ -1,17 +1,24 @@
|
||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
|
import { useTheme } from './hooks/useTheme'
|
||||||
import { requestRoute } from './api'
|
import { requestRoute } from './api'
|
||||||
import { decodePolyline } from './utils/decode'
|
import { decodePolyline } from './utils/decode'
|
||||||
import MapView from './components/MapView'
|
import MapView from './components/MapView'
|
||||||
import Panel from './components/Panel'
|
import Panel from './components/Panel'
|
||||||
|
import PlaceDetail from './components/PlaceDetail'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const mapViewRef = useRef(null)
|
const mapViewRef = useRef(null)
|
||||||
const routeDebounceRef = useRef(null)
|
const routeDebounceRef = useRef(null)
|
||||||
|
|
||||||
|
// Initialize theme system
|
||||||
|
useTheme()
|
||||||
|
|
||||||
const stops = useStore((s) => s.stops)
|
const stops = useStore((s) => s.stops)
|
||||||
const mode = useStore((s) => s.mode)
|
const mode = useStore((s) => s.mode)
|
||||||
const route = useStore((s) => s.route)
|
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 setRoute = useStore((s) => s.setRoute)
|
||||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||||
const setRouteError = useStore((s) => s.setRouteError)
|
const setRouteError = useStore((s) => s.setRouteError)
|
||||||
|
|
@ -19,10 +26,8 @@ export default function App() {
|
||||||
const setUserLocation = useStore((s) => s.setUserLocation)
|
const setUserLocation = useStore((s) => s.setUserLocation)
|
||||||
const setGeoPermission = useStore((s) => s.setGeoPermission)
|
const setGeoPermission = useStore((s) => s.setGeoPermission)
|
||||||
|
|
||||||
// Request geolocation on first route action (2+ stops)
|
// Proactive geolocation request on mount
|
||||||
const requestGeo = useCallback(() => {
|
useEffect(() => {
|
||||||
const { geoPermission } = useStore.getState()
|
|
||||||
if (geoPermission !== 'prompt') return
|
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
setGeoPermission('denied')
|
setGeoPermission('denied')
|
||||||
return
|
return
|
||||||
|
|
@ -33,28 +38,33 @@ export default function App() {
|
||||||
setGeoPermission('granted')
|
setGeoPermission('granted')
|
||||||
},
|
},
|
||||||
() => setGeoPermission('denied'),
|
() => setGeoPermission('denied'),
|
||||||
{ enableHighAccuracy: true, timeout: 10000 }
|
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
|
||||||
)
|
)
|
||||||
}, [setUserLocation, setGeoPermission])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
|
if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
|
||||||
|
|
||||||
if (stops.length < 2) {
|
routeDebounceRef.current = setTimeout(async () => {
|
||||||
|
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()
|
clearRoute()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
routeDebounceRef.current = setTimeout(async () => {
|
|
||||||
// Try to get geolocation for potential use
|
|
||||||
requestGeo()
|
|
||||||
|
|
||||||
const locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
|
||||||
setRouteLoading(true)
|
setRouteLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await requestRoute(locations, mode)
|
const data = await requestRoute(effective, mode)
|
||||||
if (data.trip) {
|
if (data.trip) {
|
||||||
setRoute(data.trip)
|
setRoute(data.trip)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -70,7 +80,7 @@ export default function App() {
|
||||||
return () => {
|
return () => {
|
||||||
if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
|
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
|
// Handle maneuver click — fly to that point on the map
|
||||||
const handleManeuverClick = useCallback(
|
const handleManeuverClick = useCallback(
|
||||||
|
|
@ -92,9 +102,10 @@ export default function App() {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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} />
|
<MapView ref={mapViewRef} />
|
||||||
<Panel onManeuverClick={handleManeuverClick} />
|
<Panel onManeuverClick={handleManeuverClick} />
|
||||||
|
<PlaceDetail />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
src/api.js
23
src/api.js
|
|
@ -1,6 +1,7 @@
|
||||||
const GEOCODE_URL = '/api/geocode'
|
const GEOCODE_URL = '/api/geocode'
|
||||||
const VALHALLA_URL = '/valhalla/route'
|
const VALHALLA_URL = '/valhalla/route'
|
||||||
const VALHALLA_OPTIMIZED_URL = '/valhalla/optimized_route'
|
const VALHALLA_OPTIMIZED_URL = '/valhalla/optimized_route'
|
||||||
|
const VALHALLA_HEIGHT_URL = '/valhalla/height'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search geocode API with abort support.
|
* Search geocode API with abort support.
|
||||||
|
|
@ -87,3 +88,25 @@ export async function requestOptimizedRoute(locations, costing = 'auto') {
|
||||||
clearTimeout(timeout)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
33
src/components/GpsOriginItem.jsx
Normal file
33
src/components/GpsOriginItem.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
import { useStore } from '../store'
|
||||||
|
|
||||||
/** Format seconds into human-friendly string */
|
|
||||||
function formatTime(seconds) {
|
function formatTime(seconds) {
|
||||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||||
if (seconds < 3600) return `${Math.round(seconds / 60)} min`
|
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`
|
return m > 0 ? `${h}h ${m}m` : `${h}h`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format distance in miles */
|
|
||||||
function formatDist(miles) {
|
function formatDist(miles) {
|
||||||
if (miles < 0.1) return `${Math.round(miles * 5280)} ft`
|
if (miles < 0.1) return `${Math.round(miles * 5280)} ft`
|
||||||
return `${miles.toFixed(1)} mi`
|
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) {
|
switch (type) {
|
||||||
case 0: return '→' // straight
|
case 0: return <MoveRight {...props} />
|
||||||
case 1: return '↗' // slight right
|
case 1: return <MoveUpRight {...props} />
|
||||||
case 2: return '→' // right
|
case 2: return <CornerUpRight {...props} />
|
||||||
case 3: return '↘' // sharp right
|
case 3: return <MoveDownRight {...props} />
|
||||||
case 4: return '↩' // u-turn right
|
case 4: case 5: return <CornerUpLeft {...props} />
|
||||||
case 5: return '↩' // u-turn left
|
case 6: return <MoveDownLeft {...props} />
|
||||||
case 6: return '↙' // sharp left
|
case 7: return <CornerUpLeft {...props} />
|
||||||
case 7: return '←' // left
|
case 8: return <MoveUpLeft {...props} />
|
||||||
case 8: return '↖' // slight left
|
case 9: return <Navigation {...props} />
|
||||||
case 9: return '●' // depart
|
case 10: case 11: case 12: return <CircleDot {...props} />
|
||||||
case 10: return '●' // arrive (straight)
|
case 15: case 16: return <RotateCw {...props} />
|
||||||
case 11: return '●' // arrive (right)
|
case 24: return <GitMerge {...props} />
|
||||||
case 12: return '●' // arrive (left)
|
case 25: return <CornerRightUp {...props} />
|
||||||
case 15: return '◎' // roundabout enter
|
case 26: return <CornerRightDown {...props} />
|
||||||
case 16: return '◎' // roundabout exit
|
default: return <MoveRight {...props} />
|
||||||
case 24: return '▲' // merge
|
|
||||||
case 25: return '⤴' // on ramp
|
|
||||||
case 26: return '⤵' // off ramp
|
|
||||||
default: return '→'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,15 +48,27 @@ export default function ManeuverList({ onManeuverClick }) {
|
||||||
if (routeLoading) {
|
if (routeLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-4">
|
<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" />
|
<div
|
||||||
<span className="ml-2 text-sm text-gray-400">Calculating route...</span>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (routeError) {
|
if (routeError) {
|
||||||
return (
|
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}
|
{routeError}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -64,22 +76,16 @@ export default function ManeuverList({ onManeuverClick }) {
|
||||||
|
|
||||||
if (!route || !route.legs) return null
|
if (!route || !route.legs) return null
|
||||||
|
|
||||||
// Compute total summary
|
|
||||||
const totalTime = route.summary?.time || 0
|
const totalTime = route.summary?.time || 0
|
||||||
const totalDist = route.summary?.length || 0
|
const totalDist = route.summary?.length || 0
|
||||||
|
|
||||||
// Flatten all maneuvers with cumulative time remaining
|
|
||||||
const allManeuvers = []
|
const allManeuvers = []
|
||||||
let timeRemaining = totalTime
|
let timeRemaining = totalTime
|
||||||
|
|
||||||
for (let legIdx = 0; legIdx < route.legs.length; legIdx++) {
|
for (let legIdx = 0; legIdx < route.legs.length; legIdx++) {
|
||||||
const leg = route.legs[legIdx]
|
const leg = route.legs[legIdx]
|
||||||
for (const man of leg.maneuvers || []) {
|
for (const man of leg.maneuvers || []) {
|
||||||
allManeuvers.push({
|
allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining })
|
||||||
...man,
|
|
||||||
_legIndex: legIdx,
|
|
||||||
timeRemaining,
|
|
||||||
})
|
|
||||||
timeRemaining -= man.time || 0
|
timeRemaining -= man.time || 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,38 +93,42 @@ export default function ManeuverList({ onManeuverClick }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* Route summary */}
|
{/* Route summary */}
|
||||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-800/60 rounded mb-2">
|
<div
|
||||||
<span className="text-sm font-medium text-white">
|
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)}
|
{formatDist(totalDist)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-300">
|
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{formatTime(totalTime)}
|
{formatTime(totalTime)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Maneuver steps */}
|
{/* 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) => (
|
{allManeuvers.map((man, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (man.begin_shape_index != null && onManeuverClick) {
|
if (man.begin_shape_index != null && onManeuverClick) onManeuverClick(man)
|
||||||
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">
|
<span className="w-5 shrink-0 mt-0.5" style={{ color: 'var(--accent)' }}>
|
||||||
{maneuverIcon(man.type)}
|
<ManeuverIcon type={man.type} />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<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'}
|
{man.instruction || man.verbal_pre_transition_instruction || 'Continue'}
|
||||||
</p>
|
</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)}
|
{formatDist(man.length || 0)}
|
||||||
{man.timeRemaining > 0 && (
|
{man.timeRemaining > 0 && (
|
||||||
<span className="ml-2">{formatTime(man.timeRemaining)} remaining</span>
|
<span className="ml-2">{formatTime(man.timeRemaining)} left</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 maplibregl from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { Protocol } from 'pmtiles'
|
import { Protocol } from 'pmtiles'
|
||||||
|
|
@ -8,17 +8,46 @@ import { decodePolyline } from '../utils/decode'
|
||||||
|
|
||||||
const ROUTE_SOURCE = 'route-source'
|
const ROUTE_SOURCE = 'route-source'
|
||||||
const ROUTE_LAYER_PREFIX = 'route-layer-'
|
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 mapRef = useRef(null)
|
||||||
const mapInstance = useRef(null)
|
const mapInstance = useRef(null)
|
||||||
const markersRef = useRef([])
|
const markersRef = useRef([])
|
||||||
const popupRef = useRef(null)
|
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 stops = useStore((s) => s.stops)
|
||||||
const route = useStore((s) => s.route)
|
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)
|
const setSheetState = useStore((s) => s.setSheetState)
|
||||||
|
|
||||||
// Expose map methods to parent
|
// Expose map methods to parent
|
||||||
|
|
@ -36,59 +65,56 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
||||||
const protocol = new Protocol()
|
const protocol = new Protocol()
|
||||||
maplibregl.addProtocol('pmtiles', protocol.tile)
|
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_CENTER = [-114.6066, 42.5736]
|
||||||
const DEFAULT_ZOOM = 10
|
const DEFAULT_ZOOM = 10
|
||||||
|
|
||||||
|
const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark'
|
||||||
|
currentThemeRef.current = initialTheme
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: mapRef.current,
|
container: mapRef.current,
|
||||||
style: {
|
style: buildStyle(initialTheme),
|
||||||
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' }),
|
|
||||||
},
|
|
||||||
center: DEFAULT_CENTER,
|
center: DEFAULT_CENTER,
|
||||||
zoom: DEFAULT_ZOOM,
|
zoom: DEFAULT_ZOOM,
|
||||||
})
|
})
|
||||||
|
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
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) {
|
if (navigator.geolocation) {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
const { latitude, longitude } = pos.coords
|
const { latitude, longitude } = pos.coords
|
||||||
// Only fly to user location if no stops have been added yet
|
|
||||||
if (useStore.getState().stops.length === 0) {
|
if (useStore.getState().stops.length === 0) {
|
||||||
map.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
|
map.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
|
||||||
}
|
}
|
||||||
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
|
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
|
||||||
useStore.getState().setGeoPermission('granted')
|
useStore.getState().setGeoPermission('granted')
|
||||||
|
createOrUpdateGpsMarker(map, latitude, longitude, null)
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
useStore.getState().setGeoPermission('denied')
|
useStore.getState().setGeoPermission('denied')
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
|
{ 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', () => {
|
map.on('click', () => {
|
||||||
// Mobile: collapse sheet when map is tapped
|
if (window.innerWidth < 768) setSheetState('collapsed')
|
||||||
if (window.innerWidth < 768) {
|
useStore.getState().clearSelectedPlace()
|
||||||
setSheetState('collapsed')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add empty route source on load
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
map.addSource(ROUTE_SOURCE, {
|
map.addSource(ROUTE_SOURCE, {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
|
|
@ -99,24 +125,116 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
||||||
mapInstance.current = map
|
mapInstance.current = map
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
|
||||||
|
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
|
||||||
maplibregl.removeProtocol('pmtiles')
|
maplibregl.removeProtocol('pmtiles')
|
||||||
map.remove()
|
map.remove()
|
||||||
}
|
}
|
||||||
}, [setSheetState])
|
}, [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
|
// Update route polyline when route changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapInstance.current
|
const map = mapInstance.current
|
||||||
if (!map || !map.isStyleLoaded()) {
|
if (!map) return
|
||||||
// Wait for style to load
|
if (!map.isStyleLoaded()) {
|
||||||
const handler = () => updateRoute(map)
|
const handler = () => updateRoute(map, route)
|
||||||
map?.on('load', handler)
|
map.once('load', handler)
|
||||||
return () => map?.off('load', handler)
|
return () => map.off('load', handler)
|
||||||
}
|
}
|
||||||
updateRoute(map)
|
updateRoute(map, route)
|
||||||
}, [route])
|
}, [route])
|
||||||
|
|
||||||
function updateRoute(map) {
|
function updateRoute(map, routeData) {
|
||||||
if (!map) return
|
if (!map) return
|
||||||
|
|
||||||
// Remove old route layers
|
// 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)) {
|
if (map.getSource(ROUTE_SOURCE)) {
|
||||||
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
|
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build GeoJSON features from route legs
|
|
||||||
const features = []
|
const features = []
|
||||||
const legColors = ['#22d3ee', '#06b6d4', '#0891b2', '#0e7490', '#155e75']
|
for (let i = 0; i < routeData.legs.length; i++) {
|
||||||
|
const leg = routeData.legs[i]
|
||||||
for (let i = 0; i < route.legs.length; i++) {
|
|
||||||
const leg = route.legs[i]
|
|
||||||
if (!leg.shape) continue
|
if (!leg.shape) continue
|
||||||
const coords = decodePolyline(leg.shape, 6)
|
const coords = decodePolyline(leg.shape, 6)
|
||||||
features.push({
|
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++) {
|
for (let i = 0; i < features.length; i++) {
|
||||||
const layerId = `${ROUTE_LAYER_PREFIX}${i}`
|
const layerId = `${ROUTE_LAYER_PREFIX}${i}`
|
||||||
if (!map.getLayer(layerId)) {
|
if (!map.getLayer(layerId)) {
|
||||||
|
|
@ -170,12 +287,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: ROUTE_SOURCE,
|
source: ROUTE_SOURCE,
|
||||||
filter: ['==', ['get', 'legIndex'], i],
|
filter: ['==', ['get', 'legIndex'], i],
|
||||||
layout: {
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
'line-join': 'round',
|
|
||||||
'line-cap': 'round',
|
|
||||||
},
|
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': legColors[i % legColors.length],
|
'line-color': routeColor || '#7a9a6b',
|
||||||
'line-width': 5,
|
'line-width': 5,
|
||||||
'line-opacity': 0.85,
|
'line-opacity': 0.85,
|
||||||
},
|
},
|
||||||
|
|
@ -190,7 +304,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
||||||
(b, c) => b.extend(c),
|
(b, c) => b.extend(c),
|
||||||
new maplibregl.LngLatBounds(allCoords[0], allCoords[0])
|
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
|
popupRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
stops.forEach((stop, i) => {
|
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||||
let color = '#3b82f6' // blue
|
const indexOffset = hasGpsOrigin ? 1 : 0
|
||||||
if (i === 0) color = '#22c55e' // green
|
|
||||||
else if (i === stops.length - 1 && stops.length > 1) color = '#ef4444' // red
|
|
||||||
|
|
||||||
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')
|
const el = document.createElement('div')
|
||||||
el.className = 'navi-marker'
|
el.className = pinClass
|
||||||
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.textContent = label
|
el.textContent = label
|
||||||
|
|
||||||
el.addEventListener('click', (e) => {
|
el.addEventListener('click', (e) => {
|
||||||
|
|
@ -231,9 +346,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
||||||
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
|
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
|
||||||
.setLngLat([stop.lon, stop.lat])
|
.setLngLat([stop.lon, stop.lat])
|
||||||
.setHTML(
|
.setHTML(
|
||||||
`<div style="color:#fff;font-size:12px;max-width:200px">
|
`<div style="font-size:12px;max-width:200px">
|
||||||
<strong>${stop.name}</strong>
|
<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>`
|
</div>`
|
||||||
)
|
)
|
||||||
.addTo(map)
|
.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 } })
|
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" />
|
return <div ref={mapRef} className="w-full h-full" />
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
|
import { Car, Footprints, Bike } from 'lucide-react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
|
|
||||||
const MODES = [
|
const MODES = [
|
||||||
{ id: 'auto', label: 'Drive', icon: '🚗' },
|
{ id: 'auto', label: 'Drive', Icon: Car },
|
||||||
{ id: 'pedestrian', label: 'Walk', icon: '🚶' },
|
{ id: 'pedestrian', label: 'Walk', Icon: Footprints },
|
||||||
{ id: 'bicycle', label: 'Bike', icon: '🚴' },
|
{ id: 'bicycle', label: 'Bike', Icon: Bike },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function ModeSelector() {
|
export default function ModeSelector() {
|
||||||
|
|
@ -11,23 +12,32 @@ export default function ModeSelector() {
|
||||||
const setMode = useStore((s) => s.setMode)
|
const setMode = useStore((s) => s.setMode)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1" role="radiogroup" aria-label="Travel mode">
|
<div
|
||||||
{MODES.map((m) => (
|
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
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={mode === m.id}
|
aria-checked={active}
|
||||||
onClick={() => setMode(m.id)}
|
onClick={() => setMode(m.id)}
|
||||||
className={`flex-1 py-1.5 px-2 rounded text-xs font-medium transition-colors ${
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-2 text-xs font-medium transition-colors duration-100"
|
||||||
mode === m.id
|
style={{
|
||||||
? 'bg-cyan-600 text-white'
|
background: active ? 'var(--accent-muted)' : 'transparent',
|
||||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
color: active ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
}`}
|
borderRight: m.id !== 'bicycle' ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="mr-1">{m.icon}</span>
|
<m.Icon size={14} />
|
||||||
{m.label}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useRef, useCallback, useEffect, useState } from 'react'
|
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Sun, Moon } from 'lucide-react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import SearchBar from './SearchBar'
|
import SearchBar from './SearchBar'
|
||||||
import StopList from './StopList'
|
import StopList from './StopList'
|
||||||
|
|
@ -18,6 +19,11 @@ export default function Panel({ onManeuverClick }) {
|
||||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||||
const sheetState = useStore((s) => s.sheetState)
|
const sheetState = useStore((s) => s.sheetState)
|
||||||
const setSheetState = useStore((s) => s.setSheetState)
|
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 [isMobile, setIsMobile] = useState(false)
|
||||||
const [optimizing, setOptimizing] = useState(false)
|
const [optimizing, setOptimizing] = useState(false)
|
||||||
|
|
@ -33,18 +39,32 @@ export default function Panel({ onManeuverClick }) {
|
||||||
return () => window.removeEventListener('resize', check)
|
return () => window.removeEventListener('resize', check)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Theme toggle
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = theme === 'dark' ? 'light' : 'dark'
|
||||||
|
setThemeOverride(next)
|
||||||
|
}
|
||||||
|
|
||||||
// Optimize stops
|
// Optimize stops
|
||||||
|
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||||
|
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||||
|
|
||||||
const handleOptimize = useCallback(async () => {
|
const handleOptimize = useCallback(async () => {
|
||||||
if (stops.length < 3 || optimizing) return
|
if (effectiveCount < 3 || optimizing) return
|
||||||
setOptimizing(true)
|
setOptimizing(true)
|
||||||
try {
|
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)
|
const data = await requestOptimizedRoute(locations, mode)
|
||||||
if (data.trip) {
|
if (data.trip) {
|
||||||
// Reorder stops based on optimized waypoint order
|
// If GPS origin was prepended, skip it from the result waypoints
|
||||||
const wpOrder = data.trip.locations
|
const wpOrder = hasGpsOrigin && userLocation
|
||||||
|
? (data.trip.locations || []).slice(1)
|
||||||
|
: data.trip.locations
|
||||||
if (wpOrder && wpOrder.length === stops.length) {
|
if (wpOrder && wpOrder.length === stops.length) {
|
||||||
// Match optimized locations back to original stops by proximity
|
|
||||||
const reordered = wpOrder.map((wp) => {
|
const reordered = wpOrder.map((wp) => {
|
||||||
let closest = stops[0]
|
let closest = stops[0]
|
||||||
let minDist = Infinity
|
let minDist = Infinity
|
||||||
|
|
@ -57,7 +77,6 @@ export default function Panel({ onManeuverClick }) {
|
||||||
}
|
}
|
||||||
return closest
|
return closest
|
||||||
})
|
})
|
||||||
// Deduplicate (in case of matching issues)
|
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
const unique = reordered.filter((s) => {
|
const unique = reordered.filter((s) => {
|
||||||
if (seen.has(s.id)) return false
|
if (seen.has(s.id)) return false
|
||||||
|
|
@ -75,7 +94,7 @@ export default function Panel({ onManeuverClick }) {
|
||||||
} finally {
|
} finally {
|
||||||
setOptimizing(false)
|
setOptimizing(false)
|
||||||
}
|
}
|
||||||
}, [stops, mode, optimizing, setStops, setRoute, setRouteError])
|
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||||
|
|
||||||
// Mobile sheet drag handling
|
// Mobile sheet drag handling
|
||||||
const handleTouchStart = useCallback((e) => {
|
const handleTouchStart = useCallback((e) => {
|
||||||
|
|
@ -86,30 +105,25 @@ export default function Panel({ onManeuverClick }) {
|
||||||
const handleTouchEnd = useCallback((e) => {
|
const handleTouchEnd = useCallback((e) => {
|
||||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||||
if (Math.abs(deltaY) < 30) return
|
if (Math.abs(deltaY) < 30) return
|
||||||
|
|
||||||
if (deltaY < 0) {
|
if (deltaY < 0) {
|
||||||
// Swipe up
|
|
||||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||||
else if (dragStartState.current === 'half') setSheetState('full')
|
else if (dragStartState.current === 'half') setSheetState('full')
|
||||||
} else {
|
} else {
|
||||||
// Swipe down
|
|
||||||
if (dragStartState.current === 'full') setSheetState('half')
|
if (dragStartState.current === 'full') setSheetState('half')
|
||||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||||
}
|
}
|
||||||
}, [setSheetState])
|
}, [setSheetState])
|
||||||
|
|
||||||
const showOptimize = stops.length >= 3
|
const showOptimize = effectiveCount >= 3
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
|
||||||
{/* Stop list */}
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<StopList />
|
<StopList />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mode selector + optimize */}
|
|
||||||
{stops.length >= 1 && (
|
{stops.length >= 1 && (
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
<ModeSelector />
|
<ModeSelector />
|
||||||
|
|
@ -117,7 +131,7 @@ export default function Panel({ onManeuverClick }) {
|
||||||
<button
|
<button
|
||||||
onClick={handleOptimize}
|
onClick={handleOptimize}
|
||||||
disabled={optimizing || routeLoading}
|
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'}
|
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -125,28 +139,46 @@ export default function Panel({ onManeuverClick }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Maneuver list */}
|
|
||||||
{(route || routeLoading || routeError) && (
|
{(route || routeLoading || routeError) && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TODO: Recents / saved places placeholder */}
|
|
||||||
{stops.length === 0 && !route && (
|
{stops.length === 0 && !route && (
|
||||||
<div className="mt-6 text-center text-gray-600 text-xs">
|
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{/* TODO: Wire recents + favorites in a later phase */}
|
<p>Search and add stops to build your route</p>
|
||||||
<p>Recent places will appear here</p>
|
|
||||||
</div>
|
</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
|
// Desktop: side panel
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
return (
|
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">
|
<div
|
||||||
<h1 className="text-lg font-semibold text-cyan-400 mb-3">Navi</h1>
|
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}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -162,7 +194,11 @@ export default function Panel({ onManeuverClick }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={sheetRef}
|
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 */}
|
{/* Drag handle */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -175,11 +211,12 @@ export default function Panel({ onManeuverClick }) {
|
||||||
else setSheetState('half')
|
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>
|
</div>
|
||||||
|
|
||||||
{sheetState !== 'collapsed' && (
|
{sheetState !== 'collapsed' && (
|
||||||
<div className="px-4 pb-4 overflow-y-auto h-[calc(100%-2rem)]">
|
<div className="px-4 pb-4 overflow-y-auto h-[calc(100%-2rem)]">
|
||||||
|
{header}
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
236
src/components/PlaceDetail.jsx
Normal file
236
src/components/PlaceDetail.jsx
Normal 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">·</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 { useStore } from '../store'
|
||||||
import { searchGeocode } from '../api'
|
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 inputRef = useRef(null)
|
||||||
const [activeIndex, setActiveIndex] = useState(-1)
|
const [activeIndex, setActiveIndex] = useState(-1)
|
||||||
const debounceRef = useRef(null)
|
const debounceRef = useRef(null)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => inputRef.current?.focus(),
|
||||||
|
}))
|
||||||
|
|
||||||
const query = useStore((s) => s.query)
|
const query = useStore((s) => s.query)
|
||||||
const results = useStore((s) => s.results)
|
const results = useStore((s) => s.results)
|
||||||
const searchLoading = useStore((s) => s.searchLoading)
|
const searchLoading = useStore((s) => s.searchLoading)
|
||||||
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
|
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
|
||||||
const stops = useStore((s) => s.stops)
|
const stops = useStore((s) => s.stops)
|
||||||
|
const pendingDestination = useStore((s) => s.pendingDestination)
|
||||||
const setQuery = useStore((s) => s.setQuery)
|
const setQuery = useStore((s) => s.setQuery)
|
||||||
const setResults = useStore((s) => s.setResults)
|
const setResults = useStore((s) => s.setResults)
|
||||||
const setSearchLoading = useStore((s) => s.setSearchLoading)
|
const setSearchLoading = useStore((s) => s.setSearchLoading)
|
||||||
const setAbortController = useStore((s) => s.setAbortController)
|
const setAbortController = useStore((s) => s.setAbortController)
|
||||||
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
|
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
|
||||||
const addStop = useStore((s) => s.addStop)
|
const addStop = useStore((s) => s.addStop)
|
||||||
|
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
||||||
|
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
|
||||||
|
|
||||||
// Focus on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const doSearch = useCallback(
|
const doSearch = useCallback(
|
||||||
async (q) => {
|
async (q) => {
|
||||||
// Abort previous
|
|
||||||
const prev = useStore.getState().abortController
|
const prev = useStore.getState().abortController
|
||||||
if (prev) prev.abort()
|
if (prev) prev.abort()
|
||||||
|
|
||||||
|
|
@ -61,19 +88,40 @@ export default function SearchBar() {
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const val = e.target.value
|
const val = e.target.value
|
||||||
setQuery(val)
|
setQuery(val)
|
||||||
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
debounceRef.current = setTimeout(() => doSearch(val), 150)
|
debounceRef.current = setTimeout(() => doSearch(val), 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
setAutocompleteOpen(false)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
const selectResult = (result) => {
|
const selectResult = (result) => {
|
||||||
addStop({
|
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,
|
lat: result.lat,
|
||||||
lon: result.lon,
|
lon: result.lon,
|
||||||
name: result.name,
|
name: result.name,
|
||||||
|
address: result.address || null,
|
||||||
|
type: result.type,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
matchCode: result.match_code,
|
matchCode: result.match_code,
|
||||||
|
raw: result.raw || {},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setQuery('')
|
setQuery('')
|
||||||
setResults([])
|
setResults([])
|
||||||
setAutocompleteOpen(false)
|
setAutocompleteOpen(false)
|
||||||
|
|
@ -83,9 +131,7 @@ export default function SearchBar() {
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (!autocompleteOpen || results.length === 0) {
|
if (!autocompleteOpen || results.length === 0) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') setAutocompleteOpen(false)
|
||||||
setAutocompleteOpen(false)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,8 +162,7 @@ export default function SearchBar() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative">
|
||||||
<div className="relative flex-1">
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -125,26 +170,43 @@ export default function SearchBar() {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
|
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
|
||||||
placeholder={atCap ? 'Max 10 stops reached' : 'Search for a place...'}
|
placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
|
||||||
disabled={atCap}
|
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"
|
className="navi-input w-full pr-8"
|
||||||
aria-label="Search places"
|
aria-label="Search places"
|
||||||
aria-expanded={autocompleteOpen}
|
aria-expanded={autocompleteOpen}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
/>
|
/>
|
||||||
{searchLoading && (
|
{/* Clear / Loading indicator */}
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
<div className="absolute right-2.5 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" />
|
{searchLoading ? (
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Autocomplete dropdown */}
|
{/* Autocomplete dropdown */}
|
||||||
{autocompleteOpen && results.length > 0 && (
|
{autocompleteOpen && results.length > 0 && (
|
||||||
<ul
|
<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"
|
role="listbox"
|
||||||
>
|
>
|
||||||
{results.map((r, i) => (
|
{results.map((r, i) => (
|
||||||
|
|
@ -152,27 +214,34 @@ export default function SearchBar() {
|
||||||
key={`${r.lat}-${r.lon}-${i}`}
|
key={`${r.lat}-${r.lon}-${i}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
className={`px-3 py-2 cursor-pointer text-sm border-b border-gray-700 last:border-b-0 ${
|
className="px-3 py-2 cursor-pointer text-sm"
|
||||||
i === activeIndex
|
style={{
|
||||||
? 'bg-gray-700 text-white'
|
background: i === activeIndex ? 'var(--accent-muted)' : 'transparent',
|
||||||
: 'text-gray-200 hover:bg-gray-700'
|
borderBottom: i < results.length - 1 ? '1px solid var(--border-subtle)' : 'none',
|
||||||
}`}
|
}}
|
||||||
onClick={() => selectResult(r)}
|
onClick={() => selectResult(r)}
|
||||||
onMouseEnter={() => setActiveIndex(i)}
|
onMouseEnter={() => setActiveIndex(i)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate flex-1">{r.name}</span>
|
<span className="shrink-0" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<span className="flex items-center gap-1 shrink-0">
|
<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' && (
|
{r.match_code?.housenumber === 'matched' && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-800 text-green-200 rounded font-medium">
|
<span
|
||||||
exact match
|
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||||
|
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
exact
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-gray-500">{r.source}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-gray-400 mt-0.5">
|
<div className="text-[11px] mt-0.5 ml-6" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{r.type} · {r.confidence}
|
{r.type}{r.confidence && r.confidence !== 'high' ? ` \u00b7 ${r.confidence}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -180,4 +249,6 @@ export default function SearchBar() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default SearchBar
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useSortable } from '@dnd-kit/sortable'
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { X, GripVertical } from 'lucide-react'
|
||||||
import { useStore } from '../store'
|
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 removeStop = useStore((s) => s.removeStop)
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
|
@ -14,62 +15,62 @@ export default function StopItem({ stop, index, total }) {
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin color logic
|
const displayIndex = index + indexOffset
|
||||||
let pinColor = 'bg-blue-500' // intermediate
|
const effectiveTotal = total + indexOffset
|
||||||
let pinLabel = String(index + 1)
|
|
||||||
if (index === 0) {
|
// Pin color from tokens
|
||||||
pinColor = 'bg-green-500'
|
let pinVar = '--pin-intermediate'
|
||||||
pinLabel = 'A'
|
if (displayIndex === 0) pinVar = '--pin-origin'
|
||||||
} else if (index === total - 1 && total > 1) {
|
else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinVar = '--pin-destination'
|
||||||
pinColor = 'bg-red-500'
|
|
||||||
pinLabel = String.fromCharCode(65 + Math.min(index, 25)) // A-Z
|
const pinLabel = String.fromCharCode(65 + Math.min(displayIndex, 25))
|
||||||
} else {
|
|
||||||
pinLabel = String.fromCharCode(65 + Math.min(index, 25))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={{
|
||||||
className="flex items-center gap-2 py-1.5 px-2 bg-gray-800/60 rounded border border-gray-700 group"
|
...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 */}
|
{/* Drag handle */}
|
||||||
<button
|
<button
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...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"
|
aria-label="Drag to reorder"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
<GripVertical size={14} />
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Pin indicator */}
|
{/* Pin indicator */}
|
||||||
<span
|
<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}
|
{pinLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Stop name */}
|
{/* 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 */}
|
{/* Remove button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeStop(stop.id)}
|
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}`}
|
aria-label={`Remove stop ${stop.name}`}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<X size={14} />
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,17 @@ import {
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import StopItem from './StopItem'
|
import StopItem from './StopItem'
|
||||||
|
import GpsOriginItem from './GpsOriginItem'
|
||||||
|
|
||||||
export default function StopList() {
|
export default function StopList() {
|
||||||
const stops = useStore((s) => s.stops)
|
const stops = useStore((s) => s.stops)
|
||||||
const reorderStops = useStore((s) => s.reorderStops)
|
const reorderStops = useStore((s) => s.reorderStops)
|
||||||
const geoPermission = useStore((s) => s.geoPermission)
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
|
@ -34,10 +40,12 @@ export default function StopList() {
|
||||||
reorderStops(arrayMove(stops, oldIndex, newIndex))
|
reorderStops(arrayMove(stops, oldIndex, newIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stops.length === 0) {
|
if (stops.length === 0 && !hasGpsOrigin) {
|
||||||
return (
|
return (
|
||||||
<div className="text-gray-500 text-xs px-2 py-3 text-center">
|
<div className="text-xs px-2 py-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{geoPermission === 'denied'
|
{pendingDestination
|
||||||
|
? 'Search for a starting point above'
|
||||||
|
: geoPermission === 'denied'
|
||||||
? 'Add a starting point and destination above'
|
? 'Add a starting point and destination above'
|
||||||
: 'Search and add stops to build your route'}
|
: 'Search and add stops to build your route'}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,14 +53,21 @@ export default function StopList() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{hasGpsOrigin && <GpsOriginItem />}
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{stops.map((stop, i) => (
|
{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>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
src/hooks/useTheme.js
Normal file
45
src/hooks/useTheme.js
Normal 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])
|
||||||
|
}
|
||||||
255
src/index.css
255
src/index.css
|
|
@ -1,38 +1,158 @@
|
||||||
@import "tailwindcss";
|
@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 {
|
html, body, #root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
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 {
|
.maplibregl-popup-content {
|
||||||
background: #1f2937 !important;
|
background: var(--bg-raised) !important;
|
||||||
border: 1px solid #374151 !important;
|
border: 1px solid var(--border) !important;
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
padding: 8px 12px !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 {
|
.maplibregl-popup-tip {
|
||||||
border-top-color: #1f2937 !important;
|
border-top-color: var(--bg-raised) !important;
|
||||||
border-bottom-color: #1f2937 !important;
|
border-bottom-color: var(--bg-raised) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maplibregl-popup-close-button {
|
.maplibregl-popup-close-button {
|
||||||
color: #9ca3af !important;
|
color: var(--text-secondary) !important;
|
||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
padding: 2px 6px !important;
|
padding: 2px 6px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maplibregl-popup-close-button:hover {
|
.maplibregl-popup-close-button:hover {
|
||||||
color: #fff !important;
|
color: var(--text-primary) !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for panels */
|
/* ═══ SCROLLBAR ═══ */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
@ -42,10 +162,123 @@ html, body, #root {
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #4b5563;
|
background: var(--border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/main.jsx
13
src/main.jsx
|
|
@ -1,10 +1,23 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<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>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
39
src/store.js
39
src/store.js
|
|
@ -54,12 +54,51 @@ export const useStore = create((set, get) => ({
|
||||||
setRouteError: (err) => set({ routeError: err, route: null }),
|
setRouteError: (err) => set({ routeError: err, route: null }),
|
||||||
clearRoute: () => set({ route: null, routeError: 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 ──
|
// ── UI state ──
|
||||||
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
||||||
panelOpen: true,
|
panelOpen: true,
|
||||||
autocompleteOpen: false,
|
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 }),
|
setSheetState: (s) => set({ sheetState: s }),
|
||||||
setPanelOpen: (open) => set({ panelOpen: open }),
|
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||||
setAutocompleteOpen: (open) => set({ autocompleteOpen: 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')
|
||||||
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue