feat: add auth-state awareness and graceful degradation

- Add /api/auth/whoami endpoint check on app load
- Store auth state in Zustand (authenticated, username, loaded)
- Hide Contacts tab when unauthenticated
- Gate fetchNearbyContacts calls on auth.authenticated
- Replace Save button with Log in affordance when unauthenticated
- Add Login/Logout buttons to panel header
- Prevent any /api/contacts/* requests from firing when unauthenticated

Public functionality (search, routing, place details) remains
fully functional for unauthenticated users.
This commit is contained in:
Matt 2026-04-27 01:26:05 +00:00
commit 0d4a807a05
29 changed files with 13091 additions and 317 deletions

View file

@ -1,7 +1,7 @@
import { useEffect, useRef, useCallback } from 'react'
import { useStore } from './store'
import { useTheme } from './hooks/useTheme'
import { requestRoute } from './api'
import { requestRoute, fetchAuthState } from './api'
import { decodePolyline } from './utils/decode'
import MapView from './components/MapView'
import Panel from './components/Panel'
@ -26,6 +26,12 @@ export default function App() {
const setRouteLoading = useStore((s) => s.setRouteLoading)
const setRouteError = useStore((s) => s.setRouteError)
const clearRoute = useStore((s) => s.clearRoute)
const setAuth = useStore((s) => s.setAuth)
// Initialize auth state on app load (single fetch, no polling)
useEffect(() => {
fetchAuthState().then(setAuth)
}, [setAuth])
// Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms)
useEffect(() => {

View file

@ -286,3 +286,27 @@ export async function fetchLandclass(lat, lon, signal) {
return null
}
}
// ── Auth API ──
/**
* Check authentication state via whoami endpoint.
* Uses redirect: manual to detect auth without triggering navigation.
* @returns {Promise<{authenticated: boolean, username: string|null}>}
*/
export async function fetchAuthState() {
try {
const resp = await fetch('/api/auth/whoami', { redirect: 'manual' })
// Redirect response means unauthenticated (Authentik SSO flow)
if (resp.type === 'opaqueredirect' || resp.status === 302) {
return { authenticated: false, username: null }
}
if (!resp.ok) {
return { authenticated: false, username: null }
}
return resp.json()
} catch {
return { authenticated: false, username: null }
}
}

View file

@ -10,8 +10,11 @@ const VALHALLA_HEIGHT_URL = '/valhalla/height'
* @param {AbortSignal} signal
* @returns {Promise<{query, results, count}>}
*/
export async function searchGeocode(query, limit = 6, signal) {
export async function searchGeocode(query, limit = 6, signal, viewport = null) {
const params = new URLSearchParams({ q: query, limit: String(limit) })
if (viewport?.lat != null) params.set('lat', String(viewport.lat))
if (viewport?.lon != null) params.set('lon', String(viewport.lon))
if (viewport?.zoom != null) params.set('zoom', String(Math.round(viewport.zoom)))
const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 })
if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`)
return resp.json()
@ -191,6 +194,19 @@ export async function fetchPlaceDetails(osmType, osmId, signal) {
}
}
export async function fetchPlaceByWikidata(wikidataId, signal) {
try {
const resp = await fetch(`/api/place/wikidata/${wikidataId}`, {
signal,
headers: { "Accept": "application/json" },
})
if (!resp.ok) return null
return resp.json()
} catch {
return null
}
}
// ── Contacts API ──
export async function fetchContacts(signal) {

View file

@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react'
import { Plus, MapPin, User, Phone, Radio } from 'lucide-react'
import { Plus, MapPin, User, Phone, Radio, LogIn } from 'lucide-react'
import { useStore } from '../store'
import { fetchContacts } from '../api'
@ -9,30 +9,40 @@ export default function ContactList() {
const setContacts = useStore((s) => s.setContacts)
const setEditingContact = useStore((s) => s.setEditingContact)
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
const auth = useStore((s) => s.auth)
const [filter, setFilter] = useState('')
const [authFailed, setAuthFailed] = useState(false)
const loadContacts = useCallback(async () => {
// Skip fetch entirely if not authenticated
if (!auth.authenticated) return
const data = await fetchContacts()
if (data?.auth === false) {
setAuthFailed(true)
return
}
if (Array.isArray(data)) {
setContacts(data)
setAuthFailed(false)
}
}, [setContacts])
}, [setContacts, auth.authenticated])
useEffect(() => {
if (!contactsLoaded) loadContacts()
}, [contactsLoaded, loadContacts])
if (auth.loaded && auth.authenticated && !contactsLoaded) {
loadContacts()
}
}, [auth.loaded, auth.authenticated, contactsLoaded, loadContacts])
if (authFailed) {
// Show login prompt if not authenticated
if (auth.loaded && !auth.authenticated) {
return (
<div className="mt-4 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Sign in to use contacts</p>
<div className="mt-6 text-center">
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
Sign in to save and sync your contacts
</p>
<button
onClick={() => { window.location.href = '/api/auth/whoami' }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
>
<LogIn size={12} />
Log in
</button>
</div>
)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,284 +1,315 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const pendingDestination = useStore((s) => s.pendingDestination)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState.startsWith('PREVIEW')
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
const showEmptyState = panelState === 'IDLE' && !pendingDestination
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
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 (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '400px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon, LogIn, LogOut } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const pendingDestination = useStore((s) => s.pendingDestination)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const auth = useStore((s) => s.auth)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
// Show contacts tab only if feature enabled AND user is authenticated
const showContacts = hasFeature('has_contacts') && auth.authenticated
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Auth handlers
const handleLogin = () => { window.location.href = '/api/auth/whoami' }
const handleLogout = () => { window.location.href = '/outpost.goauthentik.io/sign_out' }
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState.startsWith('PREVIEW')
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
const showEmptyState = panelState === 'IDLE' && !pendingDestination
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
const header = (
<div className="flex items-center justify-between mb-3">
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
<div className="flex items-center gap-1">
{auth.loaded && (
auth.authenticated ? (
<button
onClick={handleLogout}
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
style={{ color: 'var(--text-tertiary)' }}
title={`Logged in as ${auth.username}. Click to log out.`}
>
<span className="hidden sm:inline">{auth.username}</span>
<LogOut size={14} />
</button>
) : (
<button
onClick={handleLogin}
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
style={{ color: 'var(--accent)' }}
title="Log in"
>
<LogIn size={14} />
<span>Log in</span>
</button>
)
)}
<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>
</div>
)
// Desktop: side panel (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '400px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,283 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
const showManeuvers = panelState === 'ROUTE_CALCULATED'
const showEmptyState = panelState === 'IDLE'
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
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 (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '400px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,283 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
const showManeuvers = panelState === 'ROUTE_CALCULATED'
const showEmptyState = panelState === 'IDLE'
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
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 (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '360px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,283 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState === 'PREVIEW' || panelState === 'PREVIEW_ROUTING'
const showRouteSection = panelState === 'ROUTING' || panelState === 'PREVIEW_ROUTING' || panelState === 'ROUTE_CALCULATED'
const showManeuvers = panelState === 'ROUTE_CALCULATED'
const showEmptyState = panelState === 'IDLE'
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
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 (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '360px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,283 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon } from 'lucide-react'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import ContactList from './ContactList'
import { PlaceCard } from './PlaceCard'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
const selectedPlace = useStore((s) => s.selectedPlace)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const stops = useStore((s) => s.stops)
const mode = useStore((s) => s.mode)
const route = useStore((s) => s.route)
const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError)
const setStops = useStore((s) => s.setStops)
const setRoute = useStore((s) => s.setRoute)
const setRouteError = useStore((s) => s.setRouteError)
const setRouteLoading = useStore((s) => s.setRouteLoading)
const sheetState = useStore((s) => s.sheetState)
const setSheetState = useStore((s) => s.setSheetState)
const theme = useStore((s) => s.theme)
const themeOverride = useStore((s) => s.themeOverride)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const geoPermission = useStore((s) => s.geoPermission)
const activeTab = useStore((s) => s.activeTab)
const setActiveTab = useStore((s) => s.setActiveTab)
const panelState = usePanelState()
const [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
const showContacts = hasFeature('has_contacts')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Theme toggle
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setThemeOverride(next)
}
// Optimize stops
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
const handleOptimize = useCallback(async () => {
if (effectiveCount < 3 || optimizing) return
setOptimizing(true)
try {
const { userLocation } = useStore.getState()
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
if (hasGpsOrigin && userLocation) {
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
}
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
const wpOrder = hasGpsOrigin && userLocation
? (data.trip.locations || []).slice(1)
: data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
const reordered = wpOrder.map((wp) => {
let closest = stops[0]
let minDist = Infinity
for (const s of stops) {
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
if (d < minDist) {
minDist = d
closest = s
}
}
return closest
})
const seen = new Set()
const unique = reordered.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
if (unique.length === stops.length) {
setStops(unique)
}
}
setRoute(data.trip)
}
} catch (e) {
setRouteError(e.message)
} finally {
setOptimizing(false)
}
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
// Mobile sheet drag handling
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY
dragStartState.current = sheetState
}, [sheetState])
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - dragStartY.current
if (Math.abs(deltaY) < 30) return
if (deltaY < 0) {
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = effectiveCount >= 3
// Determine what to show based on panel state
const showPreviewCard = panelState.startsWith('PREVIEW')
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState)
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
const showEmptyState = panelState === 'IDLE'
// Routes tab content - now state-driven
const routesContent = (
<>
<SearchBar />
{/* Preview card when place is selected */}
{showPreviewCard && selectedPlace && (
<div className="mt-3">
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
{/* Route section with stops */}
{showRouteSection && (
<>
<div className="mt-3">
<StopList />
</div>
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="navi-btn-secondary w-full"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
</>
)}
{/* Maneuvers when route is calculated */}
{showManeuvers && (route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
<p>Search or tap the map to explore</p>
</div>
)}
</>
)
const content = (
<>
{showContacts && (
<div className="navi-tab-bar mb-3">
<button
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('routes')}
>
Routes
</button>
<button
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
onClick={() => setActiveTab('contacts')}
>
Contacts
</button>
</div>
)}
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
</>
)
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 (now 360px to accommodate PlaceCard)
if (!isMobile) {
return (
<div
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
width: '400px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{header}
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
style={{
background: 'var(--bg-raised)',
borderTop: '1px solid var(--border)',
}}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>
)}
</div>
)
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react"
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
} from "lucide-react"
import OpeningHours from "opening_hours"
@ -245,7 +245,15 @@ function CopyPopover({ address, place, onClose }) {
}
export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore()
const contacts = useStore((s) => s.contacts)
const userLocation = useStore((s) => s.userLocation)
const stops = useStore((s) => s.stops)
const geoPermission = useStore((s) => s.geoPermission)
const addStop = useStore((s) => s.addStop)
const startDirections = useStore((s) => s.startDirections)
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
const setEditingContact = useStore((s) => s.setEditingContact)
const auth = useStore((s) => s.auth)
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
@ -421,7 +429,11 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl
</>
)}
{variant === "stop" && onRemove && <button onClick={onRemove} 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)" }}><X size={13} />Remove</button>}
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
{auth.authenticated ? (
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
) : (
<button onClick={() => { window.location.href = "/api/auth/whoami" }} className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs" style={{ background: "var(--accent-muted)", color: "var(--accent)", border: "1px solid var(--border)" }} title="Log in to save places"><LogIn size={12} /><span>Save</span></button>
)}
<div className="relative">
<button onClick={() => setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy"><Copy size={14} /><ChevronDown size={10} /></button>
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}

View file

@ -0,0 +1,434 @@
import { useEffect, useState, useRef, useCallback } from "react"
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
} from "lucide-react"
import OpeningHours from "opening_hours"
import toast from "react-hot-toast"
import { useStore } from "../store"
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from "../api"
import { hasFeature } from "../config"
import { buildAddress } from "../utils/place"
const M_TO_FT = 3.28084
function formatDriveTime(seconds) {
const mins = Math.round(seconds / 60)
if (mins < 2) return "< 2 min"
if (mins < 120) return `${mins} min`
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
function parseHours(hoursStr) {
try {
const oh = new OpeningHours(hoursStr, { address: { country_code: "us", state: "Idaho" } })
const now = new Date()
const isOpen = oh.getState(now)
const nextChange = oh.getNextChange(now)
let todayStr = ""
if (isOpen) {
todayStr = "Open now"
if (nextChange) {
const closeTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
todayStr += " \u00b7 Closes " + closeTime
}
} else {
todayStr = "Closed"
if (nextChange) {
const openTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const isTodayOpen = nextChange.getDate() === now.getDate()
todayStr += " \u00b7 Opens " + (isTodayOpen ? "at " : "tomorrow ") + openTime
}
}
const week = []
for (let d = 0; d < 7; d++) {
const date = new Date(now)
const diff = (d - now.getDay() + 7) % 7
date.setDate(now.getDate() + diff)
date.setHours(0, 0, 0, 0)
const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
if (intervals.length === 0) {
week.push({ day: DAY_SHORT[d], hours: "Closed", isTodayRow: d === now.getDay() })
} else {
const parts = intervals.map(([start, end]) => {
const s = start.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const e = end.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
return s + " \u2013 " + e
})
week.push({ day: DAY_SHORT[d], hours: parts.join(", "), isTodayRow: d === now.getDay() })
}
}
return { isOpen, todayStr, week }
} catch {
return null
}
}
function formatPhone(phone) {
if (!phone) return null
const digits = phone.replace(/[^\d]/g, "")
if (digits.length === 11 && digits[0] === "1") {
return "(" + digits.slice(1, 4) + ") " + digits.slice(4, 7) + "-" + digits.slice(7)
}
if (digits.length === 10) {
return "(" + digits.slice(0, 3) + ") " + digits.slice(3, 6) + "-" + digits.slice(6)
}
return phone
}
function wheelchairLabel(val) {
if (!val) return null
const map = { yes: "Accessible", limited: "Limited access", no: "Not accessible" }
return map[val.toLowerCase()] || null
}
function wikiUrl(wp) {
if (!wp) return null
const [lang, ...rest] = wp.split(":")
const title = rest.join(":").replace(/ /g, "_")
return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title)
}
function wikiLabel(wp) {
if (!wp) return null
const [, ...rest] = wp.split(":")
return rest.join(":").replace(/_/g, " ")
}
function DetailSection({ label, icon: Icon, first, children }) {
return (
<div className="text-xs" style={{ paddingTop: first ? 0 : "0.5rem", borderTop: first ? "none" : "1px solid var(--border)" }}>
<div className="flex items-center gap-1.5 mb-1.5" style={{ color: "var(--text-tertiary)" }}>
<Icon size={12} />
<span className="uppercase text-[10px] font-medium tracking-wide">{label}</span>
</div>
{children}
</div>
)
}
function HoursDisplay({ hoursStr, first }) {
const [expanded, setExpanded] = useState(false)
const parsed = parseHours(hoursStr)
if (!parsed) return null
const { isOpen, todayStr, week } = parsed
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<button onClick={() => setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
<span style={{ color: isOpen ? "var(--success)" : "var(--text-tertiary)" }}>{todayStr}</span>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-0.5 text-[11px]">
{week.map((w) => (
<div key={w.day} className="flex justify-between" style={{ color: w.isTodayRow ? "var(--text-primary)" : "var(--text-secondary)", fontWeight: w.isTodayRow ? 500 : 400 }}>
<span>{w.day}</span>
<span>{w.hours}</span>
</div>
))}
</div>
)}
</DetailSection>
)
}
function LandclassSection({ data }) {
if (!data || !data.summary) return null
return (
<div className="mt-2 flex items-start gap-2 text-xs" style={{ color: "var(--text-secondary)" }}>
<Trees size={14} style={{ color: "var(--text-tertiary)", flexShrink: 0, marginTop: 1 }} />
<div className="flex flex-col gap-0.5">
<span>{data.summary}</span>
{data.unit_name && <span style={{ color: "var(--text-tertiary)" }}>{data.unit_name}</span>}
</div>
</div>
)
}
function PrivateLandIndicator({ data }) {
if (!data || data.gap_status !== "4") return null
return (
<div className="mt-2 px-2 py-1.5 rounded text-xs" style={{ background: "var(--warning-muted)", color: "var(--warning)", border: "1px solid var(--warning)" }}>
Private land — permission required
</div>
)
}
function EnrichmentSkeleton() {
return (
<div className="mt-3 flex flex-col gap-3 animate-pulse">
<div className="h-3 rounded w-1/3" style={{ background: "var(--bg-inset)" }} />
<div className="h-3 rounded w-2/3" style={{ background: "var(--bg-inset)" }} />
<div className="h-3 rounded w-1/2" style={{ background: "var(--bg-inset)" }} />
</div>
)
}
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags } = details
const et = extratags || {}
const hasAbout = category
const hasHours = et.opening_hours
const hasContact = et.phone || et.website || et.email
const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
const hasLinks = et.wikipedia || et.wikidata
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
let idx = 0
return (
<div className="mt-3 flex flex-col gap-2.5">
{hasAbout && (
<DetailSection label="About" icon={Info} first={idx++ === 0}>
<span className="category-badge">{category}</span>
</DetailSection>
)}
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
{hasContact && (
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.phone && <a href={"tel:" + et.phone} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Phone size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{formatPhone(et.phone)}</a>}
{et.website && <a href={et.website.startsWith("http") ? et.website : "https://" + et.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs truncate" style={{ color: "var(--accent)" }}><Globe size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}</a>}
{et.email && <a href={"mailto:" + et.email} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Mail size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.email}</a>}
</div>
</DetailSection>
)}
{hasDetails && (
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
<div className="flex flex-col gap-1 text-xs" style={{ color: "var(--text-secondary)" }}>
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")}</span>}
{et.operator && <span>Operated by {et.operator}</span>}
{et.fee && <span>{et.fee === "no" ? "Free" : "Fee: " + et.fee}</span>}
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
{et.takeaway === "yes" && <span>Takeaway available</span>}
</div>
</DetailSection>
)}
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.wikipedia && wikiUrl(et.wikipedia) && <a href={wikiUrl(et.wikipedia)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}><BookOpen size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{wikiLabel(et.wikipedia)}</a>}
{et.wikidata && <a href={"https://www.wikidata.org/wiki/" + et.wikidata} target="_blank" rel="noopener noreferrer" className="text-[11px]" style={{ color: "var(--text-tertiary)", textDecoration: "underline" }}>View on Wikidata</a>}
</div>
</DetailSection>
)}
</div>
)
}
function CopyPopover({ address, place, onClose }) {
const ref = useRef(null)
useEffect(() => {
function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() }
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [onClose])
const copyAddress = () => {
const text = [place.name, address].filter(Boolean).join("\n")
navigator.clipboard.writeText(text).then(() => toast("Address copied"), () => toast.error("Failed to copy"))
onClose()
}
const copyCoords = () => {
const text = place.lat.toFixed(6) + ", " + place.lon.toFixed(6)
navigator.clipboard.writeText(text).then(() => toast("Coordinates copied"), () => toast.error("Failed to copy"))
onClose()
}
return (
<div ref={ref} className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]" style={{ background: "var(--bg-overlay)", border: "1px solid var(--border)", boxShadow: "var(--shadow-lg)" }}>
<button onClick={address ? copyAddress : undefined} disabled={!address} className="w-full text-left px-3 py-1.5 text-xs" style={{ color: address ? "var(--text-primary)" : "var(--text-tertiary)", cursor: address ? "pointer" : "not-allowed" }}>Address</button>
<button onClick={copyCoords} className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80" style={{ color: "var(--text-primary)" }}>Coordinates</button>
</div>
)
}
export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore()
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null)
const [landclass, setLandclass] = useState(null)
const [copyOpen, setCopyOpen] = useState(false)
const placeLat = place?.lat
const placeLon = place?.lon
const osmType = place?.raw?.osm_type
const osmId = place?.raw?.osm_id
const wikidataId = place?.wikidata || place?.raw?.wikidata
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])
useEffect(() => {
if (!hasFeature("has_nominatim_details") || !osmType || !osmId) { setPlaceDetails(null); return }
const controller = new AbortController()
setPlaceDetails("loading")
fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
if (!controller.signal.aborted) {
setPlaceDetails(data || null)
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
}
}
}
})
return () => controller.abort()
}, [osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (osmType && osmId) return
if (!wikidataId) return
const controller = new AbortController()
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setPlaceDetails((prev) => ({
...(prev === "loading" ? {} : prev || {}),
description: data.description,
population: data.population,
osm_relation_id: data.osm_relation_id,
extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags },
}))
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
}
}
}
})
return () => controller.abort()
}, [wikidataId, osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (variant !== "preview" || !userLocation || placeLat == null || placeLon == null) { setDriveTime(null); return }
setDriveTime(null)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
fetchDriveTime(userLocation.lat, userLocation.lon, placeLat, placeLon, controller.signal).then((time) => { if (!controller.signal.aborted) setDriveTime(time) })
return () => { controller.abort(); clearTimeout(timeout) }
}, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return }
const controller = new AbortController()
fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label)
else if (!controller.signal.aborted) setNearbyLabel(null)
})
return () => controller.abort()
}, [placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_landclass") || placeLat == null || placeLon == null) { setLandclass(null); return }
const controller = new AbortController()
fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setLandclass(data)
if (data.summary && useStore.getState().selectedPlace?.name === "Dropped pin") {
const current = useStore.getState().selectedPlace
useStore.getState().setSelectedPlace({ ...current, name: data.summary })
}
} else if (!controller.signal.aborted) setLandclass(null)
})
return () => controller.abort()
}, [placeLat, placeLon])
if (!place) return null
const address = buildAddress(place)
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
const elevation = !elevLoading ? elevResult.value : null
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
const existingStopIndex = stops.findIndex((s) => s.lat === place.lat && s.lon === place.lon)
const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon)
const handleDirections = () => {
startDirections(place)
if (geoPermission !== "granted" && stops.length === 0) toast("Set a starting point to get directions", { icon: "\u{1F4CD}" })
}
const handleAddStop = () => {
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
clearSelectedPlace()
}
const handleSave = () => {
if (!hasFeature("has_contacts")) { toast("Saved places coming soon"); return }
if (savedContact) setEditingContact(savedContact)
else setEditingContact({ label: "", lat: place.lat, lon: place.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || "", name: place.type === "poi" && place.raw?.name ? place.raw.name : "" })
}
const closeCopy = useCallback(() => setCopyOpen(false), [])
const stopLetter = stopIndex != null ? String.fromCharCode(65 + stopIndex) : null
if (!expanded) {
return (
<div className="navi-place-card navi-place-card-collapsed flex items-center gap-2 p-2 rounded-lg cursor-pointer" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }} onClick={onToggleExpand}>
{draggable && <div {...dragHandleProps} className="cursor-grab" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
<ChevronDown size={14} style={{ color: "var(--text-tertiary)" }} />
{onRemove && <button onClick={(e) => { e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
</div>
)
}
return (
<div className="navi-place-card navi-place-card-expanded flex flex-col rounded-lg p-3" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
{draggable && <div {...dragHandleProps} className="cursor-grab mt-0.5" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
{place.type && <span className="capitalize">{place.type}</span>}
{driveTime != null && <><span>{"\u00b7"}</span><span>{formatDriveTime(driveTime)} drive</span></>}
{nearbyLabel && <><span>{"\u00b7"}</span><span>Near {nearbyLabel}</span></>}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{onToggleExpand && variant === "stop" && <button onClick={onToggleExpand} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><ChevronUp size={14} /></button>}
{onClose && <button onClick={onClose} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
</div>
</div>
{address && <div className="text-xs mb-2" style={{ color: "var(--text-secondary)" }}>{address}</div>}
<div className="flex items-center text-[11px] mb-2" style={{ color: "var(--text-tertiary)" }}>
<span>{place.lat.toFixed(6)}, {place.lon.toFixed(6)}</span>
<span className="mx-2">{"\u00b7"}</span>
<span>{elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}</span>
</div>
<LandclassSection data={landclass} />
<PrivateLandIndicator data={landclass} />
{placeDetails === "loading" && <EnrichmentSkeleton />}
{placeDetails && placeDetails !== "loading" && <EnrichmentSections details={placeDetails} />}
<div className="mt-3 pt-3 flex gap-2" style={{ borderTop: "1px solid var(--border)" }}>
{variant === "preview" && (
<>
<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)" }}>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>
)}
</>
)}
{variant === "stop" && onRemove && <button onClick={onRemove} 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)" }}><X size={13} />Remove</button>}
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
<div className="relative">
<button onClick={() => setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy"><Copy size={14} /><ChevronDown size={10} /></button>
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}
</div>
</div>
</div>
)
}
export default PlaceCard

View file

@ -0,0 +1,434 @@
import { useEffect, useState, useRef, useCallback } from "react"
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
} from "lucide-react"
import OpeningHours from "opening_hours"
import toast from "react-hot-toast"
import { useStore } from "../store"
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from "../api"
import { hasFeature } from "../config"
import { buildAddress } from "../utils/place"
const M_TO_FT = 3.28084
function formatDriveTime(seconds) {
const mins = Math.round(seconds / 60)
if (mins < 2) return "< 2 min"
if (mins < 120) return `${mins} min`
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
function parseHours(hoursStr) {
try {
const oh = new OpeningHours(hoursStr, { address: { country_code: "us", state: "Idaho" } })
const now = new Date()
const isOpen = oh.getState(now)
const nextChange = oh.getNextChange(now)
let todayStr = ""
if (isOpen) {
todayStr = "Open now"
if (nextChange) {
const closeTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
todayStr += " \u00b7 Closes " + closeTime
}
} else {
todayStr = "Closed"
if (nextChange) {
const openTime = nextChange.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const isTodayOpen = nextChange.getDate() === now.getDate()
todayStr += " \u00b7 Opens " + (isTodayOpen ? "at " : "tomorrow ") + openTime
}
}
const week = []
for (let d = 0; d < 7; d++) {
const date = new Date(now)
const diff = (d - now.getDay() + 7) % 7
date.setDate(now.getDate() + diff)
date.setHours(0, 0, 0, 0)
const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
if (intervals.length === 0) {
week.push({ day: DAY_SHORT[d], hours: "Closed", isTodayRow: d === now.getDay() })
} else {
const parts = intervals.map(([start, end]) => {
const s = start.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
const e = end.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
return s + " \u2013 " + e
})
week.push({ day: DAY_SHORT[d], hours: parts.join(", "), isTodayRow: d === now.getDay() })
}
}
return { isOpen, todayStr, week }
} catch {
return null
}
}
function formatPhone(phone) {
if (!phone) return null
const digits = phone.replace(/[^\d]/g, "")
if (digits.length === 11 && digits[0] === "1") {
return "(" + digits.slice(1, 4) + ") " + digits.slice(4, 7) + "-" + digits.slice(7)
}
if (digits.length === 10) {
return "(" + digits.slice(0, 3) + ") " + digits.slice(3, 6) + "-" + digits.slice(6)
}
return phone
}
function wheelchairLabel(val) {
if (!val) return null
const map = { yes: "Accessible", limited: "Limited access", no: "Not accessible" }
return map[val.toLowerCase()] || null
}
function wikiUrl(wp) {
if (!wp) return null
const [lang, ...rest] = wp.split(":")
const title = rest.join(":").replace(/ /g, "_")
return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title)
}
function wikiLabel(wp) {
if (!wp) return null
const [, ...rest] = wp.split(":")
return rest.join(":").replace(/_/g, " ")
}
function DetailSection({ label, icon: Icon, first, children }) {
return (
<div className="text-xs" style={{ paddingTop: first ? 0 : "0.5rem", borderTop: first ? "none" : "1px solid var(--border)" }}>
<div className="flex items-center gap-1.5 mb-1.5" style={{ color: "var(--text-tertiary)" }}>
<Icon size={12} />
<span className="uppercase text-[10px] font-medium tracking-wide">{label}</span>
</div>
{children}
</div>
)
}
function HoursDisplay({ hoursStr, first }) {
const [expanded, setExpanded] = useState(false)
const parsed = parseHours(hoursStr)
if (!parsed) return null
const { isOpen, todayStr, week } = parsed
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<button onClick={() => setExpanded((v) => !v)} className="w-full flex items-center justify-between text-xs" style={{ color: "var(--text-primary)" }}>
<span style={{ color: isOpen ? "var(--success)" : "var(--text-tertiary)" }}>{todayStr}</span>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-0.5 text-[11px]">
{week.map((w) => (
<div key={w.day} className="flex justify-between" style={{ color: w.isTodayRow ? "var(--text-primary)" : "var(--text-secondary)", fontWeight: w.isTodayRow ? 500 : 400 }}>
<span>{w.day}</span>
<span>{w.hours}</span>
</div>
))}
</div>
)}
</DetailSection>
)
}
function LandclassSection({ data }) {
if (!data || !data.summary) return null
return (
<div className="mt-2 flex items-start gap-2 text-xs" style={{ color: "var(--text-secondary)" }}>
<Trees size={14} style={{ color: "var(--text-tertiary)", flexShrink: 0, marginTop: 1 }} />
<div className="flex flex-col gap-0.5">
<span>{data.summary}</span>
{data.unit_name && <span style={{ color: "var(--text-tertiary)" }}>{data.unit_name}</span>}
</div>
</div>
)
}
function PrivateLandIndicator({ data }) {
if (!data || data.gap_status !== "4") return null
return (
<div className="mt-2 px-2 py-1.5 rounded text-xs" style={{ background: "var(--warning-muted)", color: "var(--warning)", border: "1px solid var(--warning)" }}>
Private land — permission required
</div>
)
}
function EnrichmentSkeleton() {
return (
<div className="mt-3 flex flex-col gap-3 animate-pulse">
<div className="h-3 rounded w-1/3" style={{ background: "var(--bg-inset)" }} />
<div className="h-3 rounded w-2/3" style={{ background: "var(--bg-inset)" }} />
<div className="h-3 rounded w-1/2" style={{ background: "var(--bg-inset)" }} />
</div>
)
}
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags } = details
const et = extratags || {}
const hasAbout = category
const hasHours = et.opening_hours
const hasContact = et.phone || et.website || et.email
const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
const hasLinks = et.wikipedia || et.wikidata
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
let idx = 0
return (
<div className="mt-3 flex flex-col gap-2.5">
{hasAbout && (
<DetailSection label="About" icon={Info} first={idx++ === 0}>
<span className="category-badge">{category}</span>
</DetailSection>
)}
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
{hasContact && (
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.phone && <a href={"tel:" + et.phone} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Phone size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{formatPhone(et.phone)}</a>}
{et.website && <a href={et.website.startsWith("http") ? et.website : "https://" + et.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs truncate" style={{ color: "var(--accent)" }}><Globe size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}</a>}
{et.email && <a href={"mailto:" + et.email} className="flex items-center gap-2 text-xs" style={{ color: "var(--text-primary)" }}><Mail size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{et.email}</a>}
</div>
</DetailSection>
)}
{hasDetails && (
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
<div className="flex flex-col gap-1 text-xs" style={{ color: "var(--text-secondary)" }}>
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, " ").replace(/;/g, ", ")}</span>}
{et.operator && <span>Operated by {et.operator}</span>}
{et.fee && <span>{et.fee === "no" ? "Free" : "Fee: " + et.fee}</span>}
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
{et.takeaway === "yes" && <span>Takeaway available</span>}
</div>
</DetailSection>
)}
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.wikipedia && wikiUrl(et.wikipedia) && <a href={wikiUrl(et.wikipedia)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}><BookOpen size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />{wikiLabel(et.wikipedia)}</a>}
{et.wikidata && <a href={"https://www.wikidata.org/wiki/" + et.wikidata} target="_blank" rel="noopener noreferrer" className="text-[11px]" style={{ color: "var(--text-tertiary)", textDecoration: "underline" }}>View on Wikidata</a>}
</div>
</DetailSection>
)}
</div>
)
}
function CopyPopover({ address, place, onClose }) {
const ref = useRef(null)
useEffect(() => {
function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) onClose() }
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [onClose])
const copyAddress = () => {
const text = [place.name, address].filter(Boolean).join("\n")
navigator.clipboard.writeText(text).then(() => toast("Address copied"), () => toast.error("Failed to copy"))
onClose()
}
const copyCoords = () => {
const text = place.lat.toFixed(6) + ", " + place.lon.toFixed(6)
navigator.clipboard.writeText(text).then(() => toast("Coordinates copied"), () => toast.error("Failed to copy"))
onClose()
}
return (
<div ref={ref} className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]" style={{ background: "var(--bg-overlay)", border: "1px solid var(--border)", boxShadow: "var(--shadow-lg)" }}>
<button onClick={address ? copyAddress : undefined} disabled={!address} className="w-full text-left px-3 py-1.5 text-xs" style={{ color: address ? "var(--text-primary)" : "var(--text-tertiary)", cursor: address ? "pointer" : "not-allowed" }}>Address</button>
<button onClick={copyCoords} className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80" style={{ color: "var(--text-primary)" }}>Coordinates</button>
</div>
)
}
export function PlaceCard({ place, variant = "preview", expanded = true, onToggleExpand, onClose, onRemove, stopIndex, draggable = false, dragHandleProps = {} }) {
const { contacts, userLocation, stops, geoPermission, addStop, startDirections, clearSelectedPlace, setEditingContact } = useStore()
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null)
const [landclass, setLandclass] = useState(null)
const [copyOpen, setCopyOpen] = useState(false)
const placeLat = place?.lat
const placeLon = place?.lon
const osmType = place?.raw?.osm_type
const osmId = place?.raw?.osm_id
const wikidataId = place?.wikidata || place?.raw?.wikidata
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])
useEffect(() => {
if (!hasFeature("has_nominatim_details") || !osmType || !osmId) { setPlaceDetails(null); return }
const controller = new AbortController()
setPlaceDetails("loading")
fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
if (!controller.signal.aborted) {
setPlaceDetails(data || null)
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
}
}
}
})
return () => controller.abort()
}, [osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (osmType && osmId) return
if (!wikidataId) return
const controller = new AbortController()
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setPlaceDetails((prev) => ({
...(prev === "loading" ? {} : prev || {}),
description: data.description,
population: data.population,
osm_relation_id: data.osm_relation_id,
extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags },
}))
if (data?.boundary) {
const current = useStore.getState().selectedPlace
if (current && current.lat === placeLat && current.lon === placeLon) {
useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary })
}
}
}
})
return () => controller.abort()
}, [wikidataId, osmType, osmId, placeLat, placeLon])
useEffect(() => {
if (variant !== "preview" || !userLocation || placeLat == null || placeLon == null) { setDriveTime(null); return }
setDriveTime(null)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
fetchDriveTime(userLocation.lat, userLocation.lon, placeLat, placeLon, controller.signal).then((time) => { if (!controller.signal.aborted) setDriveTime(time) })
return () => { controller.abort(); clearTimeout(timeout) }
}, [variant, userLocation?.lat, userLocation?.lon, placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_contacts") || placeLat == null || placeLon == null) { setNearbyLabel(null); return }
const controller = new AbortController()
fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
if (!controller.signal.aborted && nearby.length > 0) setNearbyLabel(nearby[0].label)
else if (!controller.signal.aborted) setNearbyLabel(null)
})
return () => controller.abort()
}, [placeLat, placeLon])
useEffect(() => {
if (!hasFeature("has_landclass") || placeLat == null || placeLon == null) { setLandclass(null); return }
const controller = new AbortController()
fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setLandclass(data)
if (data.summary && useStore.getState().selectedPlace?.name === "Dropped pin") {
const current = useStore.getState().selectedPlace
useStore.getState().setSelectedPlace({ ...current, name: data.summary })
}
} else if (!controller.signal.aborted) setLandclass(null)
})
return () => controller.abort()
}, [placeLat, placeLon])
if (!place) return null
const address = buildAddress(place)
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
const elevation = !elevLoading ? elevResult.value : null
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
const existingStopIndex = stops.findIndex((s) => s.lat === place.lat && s.lon === place.lon)
const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon)
const handleDirections = () => {
// No toast - empty origin slot is the visual prompt
startDirections(place)
}
const handleAddStop = () => {
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
clearSelectedPlace()
}
const handleSave = () => {
if (!hasFeature("has_contacts")) { toast("Saved places coming soon"); return }
if (savedContact) setEditingContact(savedContact)
else setEditingContact({ label: "", lat: place.lat, lon: place.lon, osm_type: osmType || null, osm_id: osmId || null, address: address || "", name: place.type === "poi" && place.raw?.name ? place.raw.name : "" })
}
const closeCopy = useCallback(() => setCopyOpen(false), [])
const stopLetter = stopIndex != null ? String.fromCharCode(65 + stopIndex) : null
if (!expanded) {
return (
<div className="navi-place-card navi-place-card-collapsed flex items-center gap-2 p-2 rounded-lg cursor-pointer" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }} onClick={onToggleExpand}>
{draggable && <div {...dragHandleProps} className="cursor-grab" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
<ChevronDown size={14} style={{ color: "var(--text-tertiary)" }} />
{onRemove && <button onClick={(e) => { e.stopPropagation(); onRemove() }} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
</div>
)
}
return (
<div className="navi-place-card navi-place-card-expanded flex flex-col rounded-lg p-3" style={{ background: "var(--bg-inset)", border: "1px solid var(--border)" }}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
{draggable && <div {...dragHandleProps} className="cursor-grab mt-0.5" style={{ color: "var(--text-tertiary)" }}><GripVertical size={14} /></div>}
{stopLetter && <div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0" style={{ background: "var(--accent)", color: "var(--text-inverse)" }}>{stopLetter}</div>}
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate" style={{ color: "var(--text-primary)" }}>{place.name || "Unknown place"}</span>
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
{place.type && <span className="capitalize">{place.type}</span>}
{driveTime != null && <><span>{"\u00b7"}</span><span>{formatDriveTime(driveTime)} drive</span></>}
{nearbyLabel && <><span>{"\u00b7"}</span><span>Near {nearbyLabel}</span></>}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{onToggleExpand && variant === "stop" && <button onClick={onToggleExpand} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><ChevronUp size={14} /></button>}
{onClose && <button onClick={onClose} className="p-1 rounded hover:opacity-80" style={{ color: "var(--text-tertiary)" }}><X size={14} /></button>}
</div>
</div>
{address && <div className="text-xs mb-2" style={{ color: "var(--text-secondary)" }}>{address}</div>}
<div className="flex items-center text-[11px] mb-2" style={{ color: "var(--text-tertiary)" }}>
<span>{place.lat.toFixed(6)}, {place.lon.toFixed(6)}</span>
<span className="mx-2">{"\u00b7"}</span>
<span>{elevLoading ? "..." : elevFeet != null ? elevFeet.toLocaleString() + " ft" : "\u2014"}</span>
</div>
<LandclassSection data={landclass} />
<PrivateLandIndicator data={landclass} />
{placeDetails === "loading" && <EnrichmentSkeleton />}
{placeDetails && placeDetails !== "loading" && <EnrichmentSections details={placeDetails} />}
<div className="mt-3 pt-3 flex gap-2" style={{ borderTop: "1px solid var(--border)" }}>
{variant === "preview" && (
<>
<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} />Get 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)" }}>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>
)}
</>
)}
{variant === "stop" && onRemove && <button onClick={onRemove} 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)" }}><X size={13} />Remove</button>}
<button onClick={handleSave} className="p-2 rounded-lg" style={{ background: savedContact ? "var(--accent-muted)" : "var(--tan-muted)", color: savedContact ? "var(--accent)" : "var(--tan)", border: "1px solid var(--border)" }} aria-label={savedContact ? "Edit saved contact" : "Save place"}><Bookmark size={14} fill={savedContact ? "currentColor" : "none"} /></button>
<div className="relative">
<button onClick={() => setCopyOpen((v) => !v)} className="p-2 rounded-lg flex items-center gap-0.5" style={{ background: "var(--tan-muted)", color: "var(--tan)", border: "1px solid var(--border)" }} aria-label="Copy"><Copy size={14} /><ChevronDown size={10} /></button>
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}
</div>
</div>
</div>
)
}
export default PlaceCard

View file

@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
} from 'lucide-react'
import OpeningHours from 'opening_hours'
@ -416,6 +416,7 @@ export default function PlaceDetail() {
const userLocation = useStore((s) => s.userLocation)
const contacts = useStore((s) => s.contacts)
const setEditingContact = useStore((s) => s.setEditingContact)
const auth = useStore((s) => s.auth)
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [isMobile, setIsMobile] = useState(false)
@ -742,18 +743,30 @@ export default function PlaceDetail() {
</button>
)}
<button
onClick={handleSave}
className="p-2 rounded-lg"
style={{
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
color: savedContact ? 'var(--accent)' : 'var(--tan)',
border: '1px solid var(--border)',
}}
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
>
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
</button>
{auth.authenticated ? (
<button
onClick={handleSave}
className="p-2 rounded-lg"
style={{
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
color: savedContact ? 'var(--accent)' : 'var(--tan)',
border: '1px solid var(--border)',
}}
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
>
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
</button>
) : (
<button
onClick={() => { window.location.href = '/api/auth/whoami' }}
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)', border: '1px solid var(--border)' }}
title="Log in to save places"
>
<LogIn size={12} />
<span>Save</span>
</button>
)}
{/* Copy dropdown */}
<div className="relative">

View file

@ -0,0 +1,798 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
} from 'lucide-react'
import OpeningHours from 'opening_hours'
import toast from 'react-hot-toast'
import { useStore } from '../store'
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from '../api'
import { hasFeature } from '../config'
import { buildAddress } from '../utils/place'
/** Meters to feet */
const M_TO_FT = 3.28084
/** Format drive time (seconds) to human-readable string */
function formatDriveTime(seconds) {
const mins = Math.round(seconds / 60)
if (mins < 2) return '< 2 min drive'
if (mins < 120) return `${mins} min drive`
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h ${m}m drive` : `${h}h drive`
}
// ── Opening hours helpers ──────────────────────────────────────────────
const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function parseHours(hoursStr) {
try {
const oh = new OpeningHours(hoursStr, { address: { country_code: 'us', state: 'Idaho' } })
const now = new Date()
const isOpen = oh.getState(now)
const nextChange = oh.getNextChange(now)
let todayStr = ''
if (isOpen) {
todayStr = 'Open now'
if (nextChange) {
const closeTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
todayStr += ` \u00b7 Closes at ${closeTime}`
}
} else {
todayStr = 'Closed'
if (nextChange) {
const openTime = nextChange.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
const isToday = nextChange.getDate() === now.getDate()
todayStr += ` \u00b7 Opens ${isToday ? 'at' : 'tomorrow at'} ${openTime}`
}
}
const week = []
for (let d = 0; d < 7; d++) {
const date = new Date(now)
const diff = (d - now.getDay() + 7) % 7
date.setDate(now.getDate() + diff)
date.setHours(0, 0, 0, 0)
const intervals = oh.getOpenIntervals(date, new Date(date.getTime() + 86400000))
if (intervals.length === 0) {
week.push({ day: DAY_SHORT[d], hours: 'Closed', isToday: d === now.getDay() })
} else {
const parts = intervals.map(([start, end]) => {
const s = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
const e = end.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
return `${s} \u2013 ${e}`
})
week.push({ day: DAY_SHORT[d], hours: parts.join(', '), isToday: d === now.getDay() })
}
}
return { isOpen, todayStr, week }
} catch {
return null
}
}
// ── Formatting helpers ─────────────────────────────────────────────────
function formatPhone(phone) {
if (!phone) return null
const digits = phone.replace(/[^\d]/g, '')
if (digits.length === 11 && digits[0] === '1') {
return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`
}
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
}
return phone
}
function wheelchairLabel(val) {
if (!val) return null
const map = { yes: 'Accessible', limited: 'Limited access', no: 'Not accessible' }
return map[val.toLowerCase()] || null
}
function wikiUrl(wp) {
if (!wp) return null
const match = wp.match(/^([a-z-]+):(.+)$/)
if (!match) return null
return `https://${match[1]}.wikipedia.org/wiki/${encodeURIComponent(match[2].replace(/ /g, '_'))}`
}
function wikiLabel(wp) {
if (!wp) return null
const match = wp.match(/^[a-z-]+:(.+)$/)
return match ? match[1].replace(/_/g, ' ') : wp
}
// ── Section wrapper ────────────────────────────────────────────────────
function DetailSection({ label, icon: Icon, first, children }) {
return (
<div
className="place-detail-section"
style={first ? {} : { borderTop: '1px solid var(--border-subtle)', paddingTop: '10px' }}
>
<div className="place-detail-section-header">
{Icon && <Icon size={12} style={{ opacity: 0.6 }} />}
<span>{label}</span>
</div>
{children}
</div>
)
}
// ── Hours display ──────────────────────────────────────────────────────
function HoursDisplay({ hoursStr, first }) {
const [expanded, setExpanded] = useState(false)
const parsed = parseHours(hoursStr)
if (!parsed) {
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{hoursStr}</p>
</DetailSection>
)
}
return (
<DetailSection label="Hours" icon={Clock} first={first}>
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between text-xs"
style={{ color: 'var(--text-primary)' }}
>
<span>
<span
className="inline-block w-1.5 h-1.5 rounded-full mr-1.5"
style={{ background: parsed.isOpen ? 'var(--accent)' : 'var(--tan)' }}
/>
{parsed.todayStr}
</span>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-0.5">
{parsed.week.map((d) => (
<div
key={d.day}
className="flex justify-between text-xs"
style={{
color: d.isToday ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: d.isToday ? 600 : 400,
}}
>
<span>{d.day}</span>
<span>{d.hours}</span>
</div>
))}
</div>
)}
</DetailSection>
)
}
// ── Copy popover ───────────────────────────────────────────────────────
function CopyPopover({ address, selectedPlace, onClose }) {
const ref = useRef(null)
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) onClose()
}
function handleKey(e) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKey)
}
}, [onClose])
const copyAddress = () => {
const text = [selectedPlace.name, address].filter(Boolean).join('\n')
navigator.clipboard.writeText(text).then(
() => toast('Address copied'),
() => toast.error('Failed to copy')
)
onClose()
}
const copyCoords = () => {
const text = `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`
navigator.clipboard.writeText(text).then(
() => toast('Coordinates copied'),
() => toast.error('Failed to copy')
)
onClose()
}
return (
<div
ref={ref}
className="absolute bottom-full mb-1 right-0 rounded-lg py-1 z-50 min-w-[140px]"
style={{
background: 'var(--bg-overlay)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
}}
>
<button
onClick={address ? copyAddress : undefined}
disabled={!address}
className="w-full text-left px-3 py-1.5 text-xs"
style={{
color: address ? 'var(--text-primary)' : 'var(--text-tertiary)',
cursor: address ? 'pointer' : 'not-allowed',
}}
title={!address ? 'No address available' : undefined}
>
Address
</button>
<button
onClick={copyCoords}
className="w-full text-left px-3 py-1.5 text-xs hover:opacity-80"
style={{ color: 'var(--text-primary)' }}
>
Coordinates
</button>
</div>
)
}
// ── Enrichment sections ────────────────────────────────────────────────
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags } = details
const et = extratags || {}
const hasAbout = category
const hasHours = et.opening_hours
const hasContact = et.phone || et.website || et.email
const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
const hasLinks = et.wikipedia || et.wikidata
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
let idx = 0
return (
<div className="mt-3 flex flex-col gap-2.5">
{hasAbout && (
<DetailSection label="About" icon={Info} first={idx++ === 0}>
<span className="category-badge">{category}</span>
</DetailSection>
)}
{hasHours && <HoursDisplay hoursStr={et.opening_hours} first={idx++ === 0} />}
{hasContact && (
<DetailSection label="Contact" icon={Phone} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.phone && (
<a href={`tel:${et.phone}`} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
<Phone size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{formatPhone(et.phone)}
</a>
)}
{et.website && (
<a
href={et.website.startsWith('http') ? et.website : `https://${et.website}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs truncate"
style={{ color: 'var(--accent)' }}
>
<Globe size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{et.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
)}
{et.email && (
<a href={`mailto:${et.email}`} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
<Mail size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{et.email}
</a>
)}
</div>
</DetailSection>
)}
{hasDetails && (
<DetailSection label="Details" icon={Info} first={idx++ === 0}>
<div className="flex flex-col gap-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{et.cuisine && <span>Cuisine: {et.cuisine.replace(/_/g, ' ').replace(/;/g, ', ')}</span>}
{et.operator && <span>Operated by {et.operator}</span>}
{et.fee && <span>{et.fee === 'no' ? 'Free' : `Fee: ${et.fee}`}</span>}
{et.wheelchair && wheelchairLabel(et.wheelchair) && <span>{wheelchairLabel(et.wheelchair)}</span>}
{et.takeaway === 'yes' && <span>Takeaway available</span>}
</div>
</DetailSection>
)}
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{et.wikipedia && wikiUrl(et.wikipedia) && (
<a
href={wikiUrl(et.wikipedia)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs"
style={{ color: 'var(--accent)' }}
>
<BookOpen size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
{wikiLabel(et.wikipedia)}
</a>
)}
{et.wikidata && (
<a
href={`https://www.wikidata.org/wiki/${et.wikidata}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs font-mono"
style={{ color: 'var(--text-tertiary)' }}
>
Wikidata: {et.wikidata}
</a>
)}
</div>
</DetailSection>
)}
</div>
)
}
// ── Skeleton loader ────────────────────────────────────────────────────
// ── Land classification display ──────────────────────────────────────────────────────────────────────
function LandclassSection({ data }) {
if (!data || data.is_public !== true || !data.classifications?.length) return null
return (
<DetailSection label="Public Land" icon={Trees}>
<div className="flex flex-col gap-2">
{data.classifications.map((c, i) => (
<div key={i} className="flex flex-col gap-0.5">
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
{c.unit_name}
</span>
{(c.owner_type || c.manager_name || c.designation_type) && (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{[c.owner_type, c.manager_name, c.designation_type].filter(Boolean).join(' \u203a ')}
</span>
)}
{c.public_access && c.public_access !== 'Unknown' && (
<span className="category-badge" style={{ fontSize: '10px', width: 'fit-content' }}>
{c.public_access}
</span>
)}
</div>
))}
</div>
</DetailSection>
)
}
function PrivateLandIndicator({ data }) {
if (!data || data.is_private !== true) return null
return (
<p className="mt-1 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
Private land
</p>
)
}
function EnrichmentSkeleton() {
return (
<div className="mt-3 flex flex-col gap-2.5 animate-pulse">
<div className="h-3 rounded w-16" style={{ background: 'var(--border-subtle)' }} />
<div className="h-3 rounded w-32" style={{ background: 'var(--border-subtle)' }} />
<div className="h-3 rounded w-24" style={{ background: 'var(--border-subtle)' }} />
</div>
)
}
// ── Main component ─────────────────────────────────────────────────────
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 userLocation = useStore((s) => s.userLocation)
const contacts = useStore((s) => s.contacts)
const setEditingContact = useStore((s) => s.setEditingContact)
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
const [isMobile, setIsMobile] = useState(false)
const [copyOpen, setCopyOpen] = useState(false)
const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null)
const [landclass, setLandclass] = useState(null)
const closeCopy = useCallback(() => setCopyOpen(false), [])
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Close copy popover when place changes
useEffect(() => { setCopyOpen(false) }, [selectedPlace])
// Escape key closes panel
useEffect(() => {
if (!selectedPlace) return
function handleKey(e) {
if (e.key === 'Escape') clearSelectedPlace()
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [selectedPlace, clearSelectedPlace])
// 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])
// Fetch place details when place changes (if feature enabled)
const osmType = selectedPlace?.raw?.osm_type
const osmId = selectedPlace?.raw?.osm_id
useEffect(() => {
if (!hasFeature('has_nominatim_details') || !osmType || !osmId) {
setPlaceDetails(null)
return
}
const controller = new AbortController()
setPlaceDetails('loading')
fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
if (!controller.signal.aborted) {
setPlaceDetails(data || null)
}
})
return () => controller.abort()
}, [osmType, osmId])
// Fetch wikidata enrichment when place has wikidata but no OSM details
const wikidataId = selectedPlace?.wikidata || selectedPlace?.raw?.wikidata
useEffect(() => {
// Skip if OSM details are available (they provide richer data)
if (osmType && osmId) return
// Skip if no wikidata ID
if (!wikidataId) return
const controller = new AbortController()
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
// Merge wikidata info into placeDetails (description, population, etc.)
setPlaceDetails((prev) => ({
...(prev === 'loading' ? {} : prev || {}),
description: data.description,
population: data.population,
osm_relation_id: data.osm_relation_id,
extratags: {
...(prev && prev !== 'loading' ? prev.extratags : {}),
...data.extratags,
},
}))
}
})
return () => controller.abort()
}, [wikidataId, osmType, osmId])
// Fetch drive time when place or user location changes
useEffect(() => {
if (!userLocation || placeLat == null || placeLon == null) {
setDriveTime(null)
return
}
setDriveTime(null)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
fetchDriveTime(
userLocation.lat, userLocation.lon,
placeLat, placeLon,
controller.signal
).then((time) => {
if (!controller.signal.aborted) setDriveTime(time)
})
return () => {
controller.abort()
clearTimeout(timeout)
}
}, [userLocation?.lat, userLocation?.lon, placeLat, placeLon])
// Fetch nearby contacts for proximity annotation
useEffect(() => {
if (!hasFeature('has_contacts') || placeLat == null || placeLon == null) {
setNearbyLabel(null)
return
}
const controller = new AbortController()
fetchNearbyContacts(placeLat, placeLon, 75, controller.signal).then((nearby) => {
if (!controller.signal.aborted && nearby.length > 0) {
setNearbyLabel(nearby[0].label)
} else if (!controller.signal.aborted) {
setNearbyLabel(null)
}
})
return () => controller.abort()
}, [placeLat, placeLon])
// Fetch land classification when place changes (if feature enabled)
useEffect(() => {
if (!hasFeature('has_landclass') || placeLat == null || placeLon == null) {
setLandclass(null)
return
}
const controller = new AbortController()
fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setLandclass(data)
// Upgrade "Dropped pin" name to land summary if reverse geocode didn't resolve
if (data.summary && useStore.getState().selectedPlace?.name === 'Dropped pin') {
const current = useStore.getState().selectedPlace
useStore.getState().setSelectedPlace({ ...current, name: data.summary })
}
} else if (!controller.signal.aborted) {
setLandclass(null)
}
})
return () => controller.abort()
}, [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
// 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
)
// Check if place is already saved as a contact
const savedContact = hasFeature('has_contacts')
? contacts.find((c) => {
if (c.osm_type && c.osm_id && osmType && osmId) {
return c.osm_type === osmType && c.osm_id === osmId
}
if (c.lat != null && c.lon != null) {
return Math.abs(c.lat - selectedPlace.lat) < 0.0001 && Math.abs(c.lon - selectedPlace.lon) < 0.0001
}
return false
})
: null
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 = () => {
if (!hasFeature('has_contacts')) {
toast('Saved places coming soon')
return
}
if (savedContact) {
// Edit existing contact
setEditingContact(savedContact)
} else {
// New contact pre-populated from place
setEditingContact({
label: '',
lat: selectedPlace.lat,
lon: selectedPlace.lon,
osm_type: osmType || null,
osm_id: osmId || null,
address: address || '',
name: selectedPlace.type === 'poi' && selectedPlace.raw?.name ? selectedPlace.raw.name : '',
})
}
}
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.type === 'poi' && selectedPlace.raw?.name
? selectedPlace.raw.name
: selectedPlace.name}
</h2>
{(() => {
const cat = placeDetails && placeDetails !== 'loading' ? placeDetails.category : null
const parts = []
if (cat) parts.push(cat)
if (nearbyLabel) parts.push(`near ${nearbyLabel}`)
if (driveTime != null) parts.push(formatDriveTime(driveTime))
if (parts.length === 0) return null
return (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{parts.join(' \u00b7 ')}
</span>
)
})()}
</div>
{/* Address */}
{address && (
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
{address}
</p>
)}
{/* Coordinates + elevation */}
<div className="mt-3 font-mono text-xs" style={{ color: 'var(--text-secondary)' }}>
<span>{selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)}</span>
<span className="mx-2">&middot;</span>
<span>
{elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'}
</span>
</div>
{/* OSM enrichment sections */}
{/* Land classification (PAD-US) */}
<LandclassSection data={landclass} />
<PrivateLandIndicator data={landclass} />
{/* OSM enrichment sections */}
{placeDetails === 'loading' && <EnrichmentSkeleton />}
{placeDetails && placeDetails !== 'loading' && <EnrichmentSections details={placeDetails} />}
{/* 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: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
color: savedContact ? 'var(--accent)' : 'var(--tan)',
border: '1px solid var(--border)',
}}
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
>
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
</button>
{/* Copy dropdown */}
<div className="relative">
<button
onClick={() => setCopyOpen((v) => !v)}
className="p-2 rounded-lg flex items-center gap-0.5"
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
aria-label="Copy"
>
<Copy size={14} />
<ChevronDown size={10} />
</button>
{copyOpen && (
<CopyPopover
address={address}
selectedPlace={selectedPlace}
onClose={closeCopy}
/>
)}
</div>
</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: '60vh',
overflowY: 'auto',
}}
>
{panelContent}
</div>
)
}
// Desktop: side panel
return (
<div
className="navi-place-detail navi-place-detail-active absolute top-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
style={{
left: '20rem',
width: '360px',
background: 'var(--bg-raised)',
borderRight: '1px solid var(--border)',
}}
>
{panelContent}
</div>
)
}

View file

@ -0,0 +1,320 @@
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X, User } from 'lucide-react'
import toast from 'react-hot-toast'
import { useStore } from '../store'
import { buildAddress } from '../utils/place'
import { searchGeocode } from '../api'
import { hasFeature } from '../config'
/** Get category icon based on result type/source */
function CategoryIcon({ result }) {
const type = result.type || ''
const source = result.source || ''
const size = 14
if (result._isContact) return <User size={size} />
if (source === 'nickname') return <Star size={size} />
if (type === 'coordinates') return <Crosshair size={size} />
if (type === 'locality' || type === 'city') return <Building2 size={size} />
// POI subcategories from osm_value if available
const osmVal = result.raw?.osm_value || ''
if (osmVal.includes('cafe') || osmVal.includes('coffee')) return <Coffee size={size} />
if (osmVal.includes('fuel') || osmVal.includes('gas')) return <Fuel size={size} />
if (osmVal.includes('shop') || osmVal.includes('supermarket')) return <ShoppingBag size={size} />
if (osmVal.includes('hotel') || osmVal.includes('motel')) return <Hotel size={size} />
return <MapPin size={size} />
}
const SearchBar = forwardRef(function SearchBar(_, ref) {
const inputRef = useRef(null)
const [activeIndex, setActiveIndex] = useState(-1)
const debounceRef = useRef(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}))
const query = useStore((s) => s.query)
const results = useStore((s) => s.results)
const searchLoading = useStore((s) => s.searchLoading)
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
const stops = useStore((s) => s.stops)
const pendingDestination = useStore((s) => s.pendingDestination)
const contacts = useStore((s) => s.contacts)
const setQuery = useStore((s) => s.setQuery)
const setResults = useStore((s) => s.setResults)
const setSearchLoading = useStore((s) => s.setSearchLoading)
const setAbortController = useStore((s) => s.setAbortController)
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
const addStop = useStore((s) => s.addStop)
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
const setEditingContact = useStore((s) => s.setEditingContact)
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
const mapCenter = useStore((s) => s.mapCenter)
useEffect(() => {
inputRef.current?.focus()
}, [])
const doSearch = useCallback(
async (q) => {
const prev = useStore.getState().abortController
if (prev) prev.abort()
if (!q.trim()) {
setResults([])
setAutocompleteOpen(false)
setSearchLoading(false)
return
}
// Prepend matching contacts
let contactResults = []
if (hasFeature('has_contacts') && contacts.length > 0) {
const lower = q.trim().toLowerCase()
contactResults = contacts
.filter((c) =>
(c.label || '').toLowerCase().startsWith(lower) ||
(c.name || '').toLowerCase().startsWith(lower) ||
(c.call_sign || '').toLowerCase().startsWith(lower)
)
.slice(0, 3)
.map((c) => ({
lat: c.lat,
lon: c.lon,
name: c.label,
address: c.address || c.name || '',
type: 'contact',
source: 'contacts',
match_code: null,
raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
_isContact: true,
}))
}
const ctrl = new AbortController()
setAbortController(ctrl)
setSearchLoading(true)
try {
const data = await searchGeocode(q.trim(), 6, ctrl.signal, mapCenter)
const combined = [...contactResults, ...(data.results || [])]
setResults(combined)
setAutocompleteOpen(combined.length > 0)
setActiveIndex(-1)
} catch (e) {
if (e.name !== 'AbortError') {
// Still show contacts even if geocode fails
if (contactResults.length > 0) {
setResults(contactResults)
setAutocompleteOpen(true)
} else {
setResults([])
setAutocompleteOpen(false)
}
}
} finally {
setSearchLoading(false)
}
},
[setResults, setAutocompleteOpen, setSearchLoading, setAbortController, contacts]
)
const handleChange = (e) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => doSearch(val), 150)
}
const handleClear = () => {
setQuery('')
setResults([])
setAutocompleteOpen(false)
inputRef.current?.focus()
}
const selectResult = (result) => {
const { pendingDestination: pending } = useStore.getState()
// Pure contact (no geo) → open edit modal
if (result._isContact && result.lat == null) {
setEditingContact(result.raw.contact)
setQuery('')
setResults([])
setAutocompleteOpen(false)
setActiveIndex(-1)
return
}
if (pending) {
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 {
setSelectedPlace({
lat: result.lat,
lon: result.lon,
name: result.name,
address: result.address || null,
type: result.type,
source: result.source,
matchCode: result.match_code,
raw: result.raw || {},
})
}
setQuery('')
setResults([])
setAutocompleteOpen(false)
setActiveIndex(-1)
inputRef.current?.focus()
}
const handleKeyDown = (e) => {
if (!autocompleteOpen || results.length === 0) {
if (e.key === 'Escape') setAutocompleteOpen(false)
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex((prev) => Math.min(prev + 1, results.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex((prev) => Math.max(prev - 1, -1))
break
case 'Enter':
e.preventDefault()
if (activeIndex >= 0 && activeIndex < results.length) {
selectResult(results[activeIndex])
}
break
case 'Escape':
e.preventDefault()
setAutocompleteOpen(false)
setActiveIndex(-1)
break
}
}
const atCap = stops.length >= 10
return (
<div className="relative">
<div className="relative">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
disabled={atCap}
className="navi-input w-full pr-8"
aria-label="Search places"
aria-expanded={autocompleteOpen}
aria-autocomplete="list"
role="combobox"
/>
{/* Clear / Loading indicator */}
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
{searchLoading ? (
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
/>
) : query ? (
<button
onClick={handleClear}
className="p-0.5"
style={{ color: 'var(--text-tertiary)' }}
aria-label="Clear search"
>
<X size={14} />
</button>
) : null}
</div>
</div>
{/* Autocomplete dropdown */}
{autocompleteOpen && results.length > 0 && (
<ul
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-72 overflow-y-auto"
style={{
background: 'var(--bg-overlay)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
}}
role="listbox"
>
{results.map((r, i) => {
const isPoi = r.type === 'poi' && r.raw?.name
const isContact = r._isContact
const primary = isContact ? r.name : isPoi ? r.raw.name : r.name
const secondary = isContact ? (r.address || '') : isPoi ? buildAddress(r) : null
return (
<li
key={`${r.lat}-${r.lon}-${i}`}
role="option"
aria-selected={i === activeIndex}
className="px-3 py-2 cursor-pointer text-sm"
style={{
background: i === activeIndex
? 'var(--accent-muted)'
: isContact
? 'var(--accent-muted)'
: 'transparent',
borderBottom: i < results.length - 1 ? '1px solid var(--border-subtle)' : 'none',
opacity: isContact && i !== activeIndex ? 0.85 : 1,
}}
onClick={() => selectResult(r)}
onMouseEnter={() => setActiveIndex(i)}
>
<div className="flex items-center gap-2">
<span className="shrink-0" style={{ color: isContact ? 'var(--accent)' : 'var(--text-tertiary)' }}>
<CategoryIcon result={r} />
</span>
<span className="truncate flex-1" style={{ color: 'var(--text-primary)' }}>
{primary}
</span>
<span className="flex items-center gap-1.5 shrink-0">
{isContact && (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
saved
</span>
)}
{r.match_code?.housenumber === 'matched' && (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
exact
</span>
)}
</span>
</div>
{secondary && (
<div className="text-[11px] mt-0.5 ml-6 truncate" style={{ color: 'var(--text-tertiary)' }}>
{secondary}
</div>
)}
</li>
)
})}
</ul>
)}
</div>
)
})
export default SearchBar

View file

@ -0,0 +1,117 @@
import { useState } from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '../store'
import { PlaceCard } from './PlaceCard'
import GpsOriginItem from './GpsOriginItem'
// Wrapper to make PlaceCard sortable
function SortableStopCard({ stop, index, indexOffset }) {
const removeStop = useStore((s) => s.removeStop)
const [expanded, setExpanded] = useState(false)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stop.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
// Convert stop to place format for PlaceCard
const place = {
lat: stop.lat,
lon: stop.lon,
name: stop.name,
source: stop.source,
matchCode: stop.matchCode,
type: stop.type || null,
raw: stop.raw || null,
wikidata: stop.wikidata || null,
}
return (
<div ref={setNodeRef} style={style}>
<PlaceCard
place={place}
variant="stop"
expanded={expanded}
onToggleExpand={() => setExpanded(!expanded)}
onRemove={() => removeStop(stop.id)}
stopIndex={index + indexOffset}
draggable={true}
dragHandleProps={{ ...attributes, ...listeners }}
/>
</div>
)
}
export default function StopList() {
const stops = useStore((s) => s.stops)
const reorderStops = useStore((s) => s.reorderStops)
const geoPermission = useStore((s) => s.geoPermission)
const gpsOrigin = useStore((s) => s.gpsOrigin)
const pendingDestination = useStore((s) => s.pendingDestination)
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
const indexOffset = hasGpsOrigin ? 1 : 0
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragEnd(event) {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = stops.findIndex((s) => s.id === active.id)
const newIndex = stops.findIndex((s) => s.id === over.id)
reorderStops(arrayMove(stops, oldIndex, newIndex))
}
if (stops.length === 0 && !hasGpsOrigin) {
return (
<div className="text-xs px-2 py-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
{pendingDestination
? 'Search for a starting point above'
: geoPermission === 'denied'
? 'Add a starting point and destination above'
: 'Search and add stops to build your route'}
</div>
)
}
return (
<div className="flex flex-col gap-2">
{hasGpsOrigin && <GpsOriginItem />}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
{stops.map((stop, i) => (
<SortableStopCard
key={stop.id}
stop={stop}
index={i}
indexOffset={indexOffset}
/>
))}
</SortableContext>
</DndContext>
</div>
)
}

537
src/index.css.bak.twoclick Normal file
View file

@ -0,0 +1,537 @@
@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: #ddd2b9; /* warm khaki-tan (was #ece8e1) */
--bg-raised: #e8dec8; /* raised surface (was #f5f2ec) */
--bg-overlay: #e3d9c1; /* overlay/dropdown (was #f0ece5) */
--bg-input: #e8dec8; /* input fields (was #f5f2ec) */
--text-primary: #1a1d1a;
--text-secondary: #4f5a49; /* darkened for WCAG AA on new base (was #5c6558) */
--text-tertiary: #7a8674; /* darkened proportionally (was #8a9486) */
--text-inverse: #f5f2ed;
--border: #c4b89e; /* warmer border (was #d4cfc5) */
--border-subtle: #d5cab2; /* warmer subtle border (was #e8e3db) */
--accent: #4a7040;
--accent-hover: #3d5e35;
--accent-muted: #dce8d6;
--tan: #8a7556;
--tan-muted: #f0e8d8;
--pin-origin: #4a7040;
--pin-destination: #8a5c35;
--pin-intermediate: #6b6960;
--pin-stroke: #1a1d1a;
--status-success: #4a7040;
--status-warning: #8a7040;
--status-danger: #8a4040;
--route-line: #4a7040;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
}
/* ═══ BASE STYLES ═══ */
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
}
body {
background: var(--bg-base);
color: var(--text-primary);
}
/* Mono class utility */
.font-mono {
font-family: var(--font-mono);
}
/* ═══ FOCUS RING — accent, never blue ═══ */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ═══ TRANSITIONS — respect reduced motion ═══ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ═══ MAPLIBRE POPUP ═══ */
.maplibregl-popup-content {
background: var(--bg-raised) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
padding: 8px 12px !important;
box-shadow: var(--shadow-lg) !important;
color: var(--text-primary) !important;
}
.maplibregl-popup-tip {
border-top-color: var(--bg-raised) !important;
border-bottom-color: var(--bg-raised) !important;
}
.maplibregl-popup-close-button {
color: var(--text-secondary) !important;
font-size: 16px !important;
padding: 2px 6px !important;
}
.maplibregl-popup-close-button:hover {
color: var(--text-primary) !important;
background: transparent !important;
}
/* ═══ SCROLLBAR ═══ */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
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;
}
/* ═══ LAYER CONTROL ═══ */
.layer-control {
position: absolute;
bottom: 32px;
right: 10px;
z-index: 10;
}
.layer-control-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
box-shadow: var(--shadow);
transition: color 0.1s, border-color 0.1s;
}
.layer-control-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.layer-control-popover {
position: absolute;
bottom: 44px;
right: 0;
min-width: 160px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 0;
box-shadow: var(--shadow-lg);
}
.layer-control-header {
padding: 4px 12px 6px;
font-size: var(--text-xs);
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.layer-control-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
cursor: pointer;
transition: background 0.1s;
}
.layer-control-item:hover {
background: var(--bg-overlay);
}
.layer-control-label {
font-size: var(--text-sm);
color: var(--text-primary);
}
.layer-control-toggle {
appearance: none;
width: 32px;
height: 18px;
background: var(--border);
border-radius: 9px;
position: relative;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
margin-left: 12px;
}
.layer-control-toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--text-primary);
border-radius: 50%;
transition: transform 0.15s;
}
.layer-control-toggle:checked {
background: var(--accent);
}
.layer-control-toggle:checked::after {
transform: translateX(14px);
}
/* ═══ PLACE DETAIL ENRICHMENT ═══ */
.place-detail-section {
margin-top: 2px;
}
.place-detail-section-header {
display: flex;
align-items: center;
gap: 4px;
padding-bottom: 6px;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.category-badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: var(--accent);
background: var(--accent-muted);
border-radius: 10px;
}
/* ═══ LOCATE BUTTON ═══ */
.locate-btn {
position: absolute;
bottom: 80px;
right: 10px;
z-index: 10;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
box-shadow: var(--shadow);
transition: color 0.1s, border-color 0.1s;
}
.locate-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
/* ═══ STOP REMOVE BUTTON (touch-friendly) ═══ */
.stop-remove-btn {
opacity: 0;
transition: opacity 0.15s;
}
.group:hover .stop-remove-btn {
opacity: 1;
}
/* ═══ MOBILE OVERRIDES ═══ */
@media (max-width: 767px) {
body {
overflow-x: hidden;
}
.layer-control {
bottom: auto;
top: 120px;
right: 10px;
}
.locate-btn {
bottom: auto;
top: 166px;
right: 10px;
}
.stop-remove-btn {
opacity: 0.6;
}
}
/* ═══ CONTACT MODAL ═══ */
.contact-modal-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.contact-modal {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%;
max-width: 420px;
max-height: 85vh;
overflow-y: auto;
padding: 20px;
box-shadow: var(--shadow-lg);
}
/* ═══ PANEL TAB BAR ═══ */
.navi-tab-bar {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border-subtle);
}
.navi-tab {
padding: 6px 12px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.1s, border-color 0.1s;
}
.navi-tab:hover {
color: var(--text-secondary);
}
.navi-tab-active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ═══ CONTACT LIST ITEMS ═══ */
.contact-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.1s;
}
.contact-item:hover {
background: var(--bg-overlay);
}

View file

@ -109,6 +109,10 @@ export const useStore = create((set, get) => ({
localStorage.removeItem('navi-theme-override')
}
},
// ── Auth state ──
auth: { authenticated: false, username: null, loaded: false },
setAuth: (auth) => set({ auth: { ...auth, loaded: true } }),
// ── Contacts ──
contacts: [],
contactsLoaded: false,

141
src/store.js.bak.dupstop Normal file
View file

@ -0,0 +1,141 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
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, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: 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 {
// GPS denied, no stops: add destination, show empty origin slot
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
// ── Panel state selector ──
// Returns string state, prioritizing preview to allow it alongside any route state
export const usePanelState = () => {
return useStore((s) => {
const hasPreview = !!s.selectedPlace
const hasRoute = !!s.route
const hasStops = s.stops.length >= 1
if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
if (hasPreview && hasStops) return "PREVIEW_ROUTING"
if (hasPreview) return "PREVIEW"
if (hasRoute) return "ROUTE_CALCULATED"
if (hasStops) return "ROUTING"
return "IDLE"
})
}

View file

@ -0,0 +1,139 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
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, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: 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 {
// GPS denied, no stops: add destination, show empty origin slot
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
// GPS denied, no stops: add destination, show empty origin slot
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
// ── Panel state selector ──
// IDLE | PREVIEW | ROUTING | PREVIEW_ROUTING | ROUTE_CALCULATED
export const usePanelState = () => {
return useStore((s) => {
if (s.route) return "ROUTE_CALCULATED"
if (s.selectedPlace && s.stops.length >= 1) return "PREVIEW_ROUTING"
if (s.selectedPlace) return "PREVIEW"
if (s.stops.length >= 1) return "ROUTING"
return "IDLE"
})
}

118
src/store.js.bak.twoclick Normal file
View file

@ -0,0 +1,118 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
gpsOrigin: true, // whether GPS should be used as origin when available
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
setSelectedPlace: (place) => set({ selectedPlace: place }),
clearSelectedPlace: () => set({ selectedPlace: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
startDirections: (place) => {
const { geoPermission, stops, addStop, clearStops } = get()
if (geoPermission === 'granted') {
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ gpsOrigin: true, selectedPlace: null })
} else if (stops.length > 0) {
const origin = stops[0]
clearStops()
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))

133
src/store.js.bak.uxfix Normal file
View file

@ -0,0 +1,133 @@
import { create } from 'zustand'
export const useStore = create((set, get) => ({
// ── Search state ──
query: '',
results: [],
searchLoading: false,
abortController: null,
setQuery: (query) => set({ query }),
setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Stop list ──
stops: [],
// Each stop: { id, lat, lon, name, source, matchCode, isOrigin }
addStop: (stop) => {
const { stops } = get()
if (stops.length >= 10) return false
set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] })
return true
},
removeStop: (id) => {
set({ stops: get().stops.filter((s) => s.id !== id) })
},
reorderStops: (newStops) => set({ stops: newStops }),
clearStops: () => set({ stops: [] }),
setStops: (stops) => set({ stops }),
// ── Geolocation ──
userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied'
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
// ── Route ──
route: null, // Valhalla response (trip object)
routeLoading: false,
routeError: null,
setRoute: (route) => set({ route, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
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, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
startDirections: (place) => {
const { geoPermission, stops, addStop, clearStops } = get()
if (geoPermission === 'granted') {
clearStops()
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ gpsOrigin: true, selectedPlace: null })
} else if (stops.length > 0) {
const origin = stops[0]
clearStops()
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
set({ pendingDestination: place, selectedPlace: null })
}
},
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
setTheme: (theme) => set({ theme }),
setThemeOverride: (override) => {
set({ themeOverride: override })
if (override) {
localStorage.setItem('navi-theme-override', override)
} else {
localStorage.removeItem('navi-theme-override')
}
},
// ── Contacts ──
contacts: [],
contactsLoaded: false,
activeTab: 'routes', // 'routes' | 'contacts'
editingContact: null, // null=closed, {}=new, {id:N}=edit
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
// ── Panel state selector ──
// IDLE | PREVIEW | ROUTING | PREVIEW_ROUTING | ROUTE_CALCULATED
export const usePanelState = () => {
return useStore((s) => {
if (s.route) return "ROUTE_CALCULATED"
if (s.selectedPlace && s.stops.length >= 1) return "PREVIEW_ROUTING"
if (s.selectedPlace) return "PREVIEW"
if (s.stops.length >= 1) return "ROUTING"
return "IDLE"
})
}

View file

@ -40,6 +40,10 @@ export const useStore = create((set, get) => ({
setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }),
// ── Mode ──
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
setMode: (mode) => set({ mode }),
@ -55,12 +59,15 @@ export const useStore = create((set, get) => ({
clearRoute: () => set({ route: null, routeError: null }),
// ── Place detail ──
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
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 }),
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
setClickMarker: (marker) => set({ clickMarker: marker }),
clearClickMarker: () => set({ clickMarker: null }),
setGpsOrigin: (val) => set({ gpsOrigin: val }),
setPendingDestination: (place) => set({ pendingDestination: place }),
clearPendingDestination: () => set({ pendingDestination: null }),
@ -78,6 +85,7 @@ export const useStore = create((set, get) => ({
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
set({ selectedPlace: null })
} else {
// GPS denied, no stops: set pendingDestination only; origin-picker will add both
set({ pendingDestination: place, selectedPlace: null })
}
},
@ -112,3 +120,20 @@ export const useStore = create((set, get) => ({
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }),
}))
// ── Panel state selector ──
// Returns string state, prioritizing preview to allow it alongside any route state
export const usePanelState = () => {
return useStore((s) => {
const hasPreview = !!s.selectedPlace
const hasRoute = !!s.route
const hasStops = s.stops.length >= 1
if (hasPreview && hasRoute) return "PREVIEW_CALCULATED"
if (hasPreview && hasStops) return "PREVIEW_ROUTING"
if (hasPreview) return "PREVIEW"
if (hasRoute) return "ROUTE_CALCULATED"
if (hasStops) return "ROUTING"
return "IDLE"
})
}