mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
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:
parent
616d01623d
commit
0d4a807a05
29 changed files with 13091 additions and 317 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
import { useTheme } from './hooks/useTheme'
|
import { useTheme } from './hooks/useTheme'
|
||||||
import { requestRoute } from './api'
|
import { requestRoute, fetchAuthState } from './api'
|
||||||
import { decodePolyline } from './utils/decode'
|
import { decodePolyline } from './utils/decode'
|
||||||
import MapView from './components/MapView'
|
import MapView from './components/MapView'
|
||||||
import Panel from './components/Panel'
|
import Panel from './components/Panel'
|
||||||
|
|
@ -26,6 +26,12 @@ export default function App() {
|
||||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||||
const setRouteError = useStore((s) => s.setRouteError)
|
const setRouteError = useStore((s) => s.setRouteError)
|
||||||
const clearRoute = useStore((s) => s.clearRoute)
|
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)
|
// Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
24
src/api.js
24
src/api.js
|
|
@ -286,3 +286,27 @@ export async function fetchLandclass(lat, lon, signal) {
|
||||||
return null
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ const VALHALLA_HEIGHT_URL = '/valhalla/height'
|
||||||
* @param {AbortSignal} signal
|
* @param {AbortSignal} signal
|
||||||
* @returns {Promise<{query, results, count}>}
|
* @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) })
|
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 })
|
const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 })
|
||||||
if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`)
|
if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`)
|
||||||
return resp.json()
|
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 ──
|
// ── Contacts API ──
|
||||||
|
|
||||||
export async function fetchContacts(signal) {
|
export async function fetchContacts(signal) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
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 { useStore } from '../store'
|
||||||
import { fetchContacts } from '../api'
|
import { fetchContacts } from '../api'
|
||||||
|
|
||||||
|
|
@ -9,30 +9,40 @@ export default function ContactList() {
|
||||||
const setContacts = useStore((s) => s.setContacts)
|
const setContacts = useStore((s) => s.setContacts)
|
||||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||||
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
||||||
|
const auth = useStore((s) => s.auth)
|
||||||
|
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [authFailed, setAuthFailed] = useState(false)
|
|
||||||
|
|
||||||
const loadContacts = useCallback(async () => {
|
const loadContacts = useCallback(async () => {
|
||||||
|
// Skip fetch entirely if not authenticated
|
||||||
|
if (!auth.authenticated) return
|
||||||
const data = await fetchContacts()
|
const data = await fetchContacts()
|
||||||
if (data?.auth === false) {
|
|
||||||
setAuthFailed(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setContacts(data)
|
setContacts(data)
|
||||||
setAuthFailed(false)
|
|
||||||
}
|
}
|
||||||
}, [setContacts])
|
}, [setContacts, auth.authenticated])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contactsLoaded) loadContacts()
|
if (auth.loaded && auth.authenticated && !contactsLoaded) {
|
||||||
}, [contactsLoaded, loadContacts])
|
loadContacts()
|
||||||
|
}
|
||||||
|
}, [auth.loaded, auth.authenticated, contactsLoaded, loadContacts])
|
||||||
|
|
||||||
if (authFailed) {
|
// Show login prompt if not authenticated
|
||||||
|
if (auth.loaded && !auth.authenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
<div className="mt-6 text-center">
|
||||||
<p>Sign in to use contacts</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1403
src/components/MapView.jsx.bak.boundary
Normal file
1403
src/components/MapView.jsx.bak.boundary
Normal file
File diff suppressed because it is too large
Load diff
1328
src/components/MapView.jsx.bak.labelclick
Normal file
1328
src/components/MapView.jsx.bak.labelclick
Normal file
File diff suppressed because it is too large
Load diff
1315
src/components/MapView.jsx.bak.poihover
Normal file
1315
src/components/MapView.jsx.bak.poihover
Normal file
File diff suppressed because it is too large
Load diff
1514
src/components/MapView.jsx.bak.regressions
Normal file
1514
src/components/MapView.jsx.bak.regressions
Normal file
File diff suppressed because it is too large
Load diff
1256
src/components/MapView.jsx.bak.twoclick
Normal file
1256
src/components/MapView.jsx.bak.twoclick
Normal file
File diff suppressed because it is too large
Load diff
1514
src/components/MapView.jsx.bak.uxfix2
Normal file
1514
src/components/MapView.jsx.bak.uxfix2
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,284 +1,315 @@
|
||||||
import { useRef, useCallback, useEffect, useState } from 'react'
|
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||||
import { Sun, Moon } from 'lucide-react'
|
import { Sun, Moon, LogIn, LogOut } from 'lucide-react'
|
||||||
import { useStore, usePanelState } from '../store'
|
import { useStore, usePanelState } from '../store'
|
||||||
import { hasFeature } from '../config'
|
import { hasFeature } from '../config'
|
||||||
import SearchBar from './SearchBar'
|
import SearchBar from './SearchBar'
|
||||||
import StopList from './StopList'
|
import StopList from './StopList'
|
||||||
import ModeSelector from './ModeSelector'
|
import ModeSelector from './ModeSelector'
|
||||||
import ManeuverList from './ManeuverList'
|
import ManeuverList from './ManeuverList'
|
||||||
import ContactList from './ContactList'
|
import ContactList from './ContactList'
|
||||||
import { PlaceCard } from './PlaceCard'
|
import { PlaceCard } from './PlaceCard'
|
||||||
import { requestOptimizedRoute } from '../api'
|
import { requestOptimizedRoute } from '../api'
|
||||||
|
|
||||||
export default function Panel({ onManeuverClick }) {
|
export default function Panel({ onManeuverClick }) {
|
||||||
const selectedPlace = useStore((s) => s.selectedPlace)
|
const selectedPlace = useStore((s) => s.selectedPlace)
|
||||||
const pendingDestination = useStore((s) => s.pendingDestination)
|
const pendingDestination = useStore((s) => s.pendingDestination)
|
||||||
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
||||||
const stops = useStore((s) => s.stops)
|
const stops = useStore((s) => s.stops)
|
||||||
const mode = useStore((s) => s.mode)
|
const mode = useStore((s) => s.mode)
|
||||||
const route = useStore((s) => s.route)
|
const route = useStore((s) => s.route)
|
||||||
const routeLoading = useStore((s) => s.routeLoading)
|
const routeLoading = useStore((s) => s.routeLoading)
|
||||||
const routeError = useStore((s) => s.routeError)
|
const routeError = useStore((s) => s.routeError)
|
||||||
const setStops = useStore((s) => s.setStops)
|
const setStops = useStore((s) => s.setStops)
|
||||||
const setRoute = useStore((s) => s.setRoute)
|
const setRoute = useStore((s) => s.setRoute)
|
||||||
const setRouteError = useStore((s) => s.setRouteError)
|
const setRouteError = useStore((s) => s.setRouteError)
|
||||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||||
const sheetState = useStore((s) => s.sheetState)
|
const sheetState = useStore((s) => s.sheetState)
|
||||||
const setSheetState = useStore((s) => s.setSheetState)
|
const setSheetState = useStore((s) => s.setSheetState)
|
||||||
const theme = useStore((s) => s.theme)
|
const theme = useStore((s) => s.theme)
|
||||||
const themeOverride = useStore((s) => s.themeOverride)
|
const themeOverride = useStore((s) => s.themeOverride)
|
||||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||||
const geoPermission = useStore((s) => s.geoPermission)
|
const geoPermission = useStore((s) => s.geoPermission)
|
||||||
const activeTab = useStore((s) => s.activeTab)
|
const activeTab = useStore((s) => s.activeTab)
|
||||||
const setActiveTab = useStore((s) => s.setActiveTab)
|
const auth = useStore((s) => s.auth)
|
||||||
|
const setActiveTab = useStore((s) => s.setActiveTab)
|
||||||
const panelState = usePanelState()
|
|
||||||
|
const panelState = usePanelState()
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
|
||||||
const [optimizing, setOptimizing] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const sheetRef = useRef(null)
|
const [optimizing, setOptimizing] = useState(false)
|
||||||
const dragStartY = useRef(0)
|
const sheetRef = useRef(null)
|
||||||
const dragStartState = useRef('half')
|
const dragStartY = useRef(0)
|
||||||
|
const dragStartState = useRef('half')
|
||||||
const showContacts = hasFeature('has_contacts')
|
|
||||||
|
// Show contacts tab only if feature enabled AND user is authenticated
|
||||||
// Responsive detection
|
const showContacts = hasFeature('has_contacts') && auth.authenticated
|
||||||
useEffect(() => {
|
|
||||||
const check = () => setIsMobile(window.innerWidth < 768)
|
// Responsive detection
|
||||||
check()
|
useEffect(() => {
|
||||||
window.addEventListener('resize', check)
|
const check = () => setIsMobile(window.innerWidth < 768)
|
||||||
return () => window.removeEventListener('resize', check)
|
check()
|
||||||
}, [])
|
window.addEventListener('resize', check)
|
||||||
|
return () => window.removeEventListener('resize', check)
|
||||||
// Theme toggle
|
}, [])
|
||||||
const toggleTheme = () => {
|
|
||||||
const next = theme === 'dark' ? 'light' : 'dark'
|
// Theme toggle
|
||||||
setThemeOverride(next)
|
const toggleTheme = () => {
|
||||||
}
|
const next = theme === 'dark' ? 'light' : 'dark'
|
||||||
|
setThemeOverride(next)
|
||||||
// Optimize stops
|
}
|
||||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
|
||||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
// Auth handlers
|
||||||
|
const handleLogin = () => { window.location.href = '/api/auth/whoami' }
|
||||||
const handleOptimize = useCallback(async () => {
|
const handleLogout = () => { window.location.href = '/outpost.goauthentik.io/sign_out' }
|
||||||
if (effectiveCount < 3 || optimizing) return
|
|
||||||
setOptimizing(true)
|
// Optimize stops
|
||||||
try {
|
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||||
const { userLocation } = useStore.getState()
|
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
|
||||||
if (hasGpsOrigin && userLocation) {
|
const handleOptimize = useCallback(async () => {
|
||||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
if (effectiveCount < 3 || optimizing) return
|
||||||
}
|
setOptimizing(true)
|
||||||
const data = await requestOptimizedRoute(locations, mode)
|
try {
|
||||||
if (data.trip) {
|
const { userLocation } = useStore.getState()
|
||||||
const wpOrder = hasGpsOrigin && userLocation
|
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||||
? (data.trip.locations || []).slice(1)
|
if (hasGpsOrigin && userLocation) {
|
||||||
: data.trip.locations
|
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||||
if (wpOrder && wpOrder.length === stops.length) {
|
}
|
||||||
const reordered = wpOrder.map((wp) => {
|
const data = await requestOptimizedRoute(locations, mode)
|
||||||
let closest = stops[0]
|
if (data.trip) {
|
||||||
let minDist = Infinity
|
const wpOrder = hasGpsOrigin && userLocation
|
||||||
for (const s of stops) {
|
? (data.trip.locations || []).slice(1)
|
||||||
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
: data.trip.locations
|
||||||
if (d < minDist) {
|
if (wpOrder && wpOrder.length === stops.length) {
|
||||||
minDist = d
|
const reordered = wpOrder.map((wp) => {
|
||||||
closest = s
|
let closest = stops[0]
|
||||||
}
|
let minDist = Infinity
|
||||||
}
|
for (const s of stops) {
|
||||||
return closest
|
const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon)
|
||||||
})
|
if (d < minDist) {
|
||||||
const seen = new Set()
|
minDist = d
|
||||||
const unique = reordered.filter((s) => {
|
closest = s
|
||||||
if (seen.has(s.id)) return false
|
}
|
||||||
seen.add(s.id)
|
}
|
||||||
return true
|
return closest
|
||||||
})
|
})
|
||||||
if (unique.length === stops.length) {
|
const seen = new Set()
|
||||||
setStops(unique)
|
const unique = reordered.filter((s) => {
|
||||||
}
|
if (seen.has(s.id)) return false
|
||||||
}
|
seen.add(s.id)
|
||||||
setRoute(data.trip)
|
return true
|
||||||
}
|
})
|
||||||
} catch (e) {
|
if (unique.length === stops.length) {
|
||||||
setRouteError(e.message)
|
setStops(unique)
|
||||||
} finally {
|
}
|
||||||
setOptimizing(false)
|
}
|
||||||
}
|
setRoute(data.trip)
|
||||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
}
|
||||||
|
} catch (e) {
|
||||||
// Mobile sheet drag handling
|
setRouteError(e.message)
|
||||||
const handleTouchStart = useCallback((e) => {
|
} finally {
|
||||||
dragStartY.current = e.touches[0].clientY
|
setOptimizing(false)
|
||||||
dragStartState.current = sheetState
|
}
|
||||||
}, [sheetState])
|
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||||
|
|
||||||
const handleTouchEnd = useCallback((e) => {
|
// Mobile sheet drag handling
|
||||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
const handleTouchStart = useCallback((e) => {
|
||||||
if (Math.abs(deltaY) < 30) return
|
dragStartY.current = e.touches[0].clientY
|
||||||
if (deltaY < 0) {
|
dragStartState.current = sheetState
|
||||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
}, [sheetState])
|
||||||
else if (dragStartState.current === 'half') setSheetState('full')
|
|
||||||
} else {
|
const handleTouchEnd = useCallback((e) => {
|
||||||
if (dragStartState.current === 'full') setSheetState('half')
|
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
if (Math.abs(deltaY) < 30) return
|
||||||
}
|
if (deltaY < 0) {
|
||||||
}, [setSheetState])
|
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||||
|
else if (dragStartState.current === 'half') setSheetState('full')
|
||||||
const showOptimize = effectiveCount >= 3
|
} else {
|
||||||
|
if (dragStartState.current === 'full') setSheetState('half')
|
||||||
// Determine what to show based on panel state
|
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||||
const showPreviewCard = panelState.startsWith('PREVIEW')
|
}
|
||||||
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
|
}, [setSheetState])
|
||||||
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
|
|
||||||
const showEmptyState = panelState === 'IDLE' && !pendingDestination
|
const showOptimize = effectiveCount >= 3
|
||||||
|
|
||||||
// Routes tab content - now state-driven
|
// Determine what to show based on panel state
|
||||||
const routesContent = (
|
const showPreviewCard = panelState.startsWith('PREVIEW')
|
||||||
<>
|
const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination
|
||||||
<SearchBar />
|
const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED'
|
||||||
|
const showEmptyState = panelState === 'IDLE' && !pendingDestination
|
||||||
{/* Preview card when place is selected */}
|
|
||||||
{showPreviewCard && selectedPlace && (
|
// Routes tab content - now state-driven
|
||||||
<div className="mt-3">
|
const routesContent = (
|
||||||
<PlaceCard
|
<>
|
||||||
place={selectedPlace}
|
<SearchBar />
|
||||||
variant="preview"
|
|
||||||
expanded={true}
|
{/* Preview card when place is selected */}
|
||||||
onClose={clearSelectedPlace}
|
{showPreviewCard && selectedPlace && (
|
||||||
/>
|
<div className="mt-3">
|
||||||
</div>
|
<PlaceCard
|
||||||
)}
|
place={selectedPlace}
|
||||||
|
variant="preview"
|
||||||
{/* Route section with stops */}
|
expanded={true}
|
||||||
{showRouteSection && (
|
onClose={clearSelectedPlace}
|
||||||
<>
|
/>
|
||||||
<div className="mt-3">
|
</div>
|
||||||
<StopList />
|
)}
|
||||||
</div>
|
|
||||||
|
{/* Route section with stops */}
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
{showRouteSection && (
|
||||||
<ModeSelector />
|
<>
|
||||||
{showOptimize && (
|
<div className="mt-3">
|
||||||
<button
|
<StopList />
|
||||||
onClick={handleOptimize}
|
</div>
|
||||||
disabled={optimizing || routeLoading}
|
|
||||||
className="navi-btn-secondary w-full"
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
>
|
<ModeSelector />
|
||||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
{showOptimize && (
|
||||||
</button>
|
<button
|
||||||
)}
|
onClick={handleOptimize}
|
||||||
</div>
|
disabled={optimizing || routeLoading}
|
||||||
</>
|
className="navi-btn-secondary w-full"
|
||||||
)}
|
>
|
||||||
|
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||||
{/* Maneuvers when route is calculated */}
|
</button>
|
||||||
{showManeuvers && (route || routeLoading || routeError) && (
|
)}
|
||||||
<div className="mt-3">
|
</div>
|
||||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
</>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{/* Maneuvers when route is calculated */}
|
||||||
{/* Empty state */}
|
{showManeuvers && (route || routeLoading || routeError) && (
|
||||||
{showEmptyState && (
|
<div className="mt-3">
|
||||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||||
<p>Search or tap the map to explore</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</>
|
{/* Empty state */}
|
||||||
)
|
{showEmptyState && (
|
||||||
|
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
const content = (
|
<p>Search or tap the map to explore</p>
|
||||||
<>
|
</div>
|
||||||
{showContacts && (
|
)}
|
||||||
<div className="navi-tab-bar mb-3">
|
</>
|
||||||
<button
|
)
|
||||||
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('routes')}
|
const content = (
|
||||||
>
|
<>
|
||||||
Routes
|
{showContacts && (
|
||||||
</button>
|
<div className="navi-tab-bar mb-3">
|
||||||
<button
|
<button
|
||||||
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
className={`navi-tab ${activeTab === 'routes' ? 'navi-tab-active' : ''}`}
|
||||||
onClick={() => setActiveTab('contacts')}
|
onClick={() => setActiveTab('routes')}
|
||||||
>
|
>
|
||||||
Contacts
|
Routes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
)}
|
className={`navi-tab ${activeTab === 'contacts' ? 'navi-tab-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('contacts')}
|
||||||
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
>
|
||||||
</>
|
Contacts
|
||||||
)
|
</button>
|
||||||
|
</div>
|
||||||
const header = (
|
)}
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
{(!showContacts || activeTab === 'routes') ? routesContent : <ContactList />}
|
||||||
<button
|
</>
|
||||||
onClick={toggleTheme}
|
)
|
||||||
className="p-1.5 rounded"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
const header = (
|
||||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
<div className="flex items-center justify-between mb-3">
|
||||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||||
>
|
<div className="flex items-center gap-1">
|
||||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
{auth.loaded && (
|
||||||
</button>
|
auth.authenticated ? (
|
||||||
</div>
|
<button
|
||||||
)
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
||||||
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
if (!isMobile) {
|
title={`Logged in as ${auth.username}. Click to log out.`}
|
||||||
return (
|
>
|
||||||
<div
|
<span className="hidden sm:inline">{auth.username}</span>
|
||||||
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
<LogOut size={14} />
|
||||||
style={{
|
</button>
|
||||||
width: '400px',
|
) : (
|
||||||
background: 'var(--bg-raised)',
|
<button
|
||||||
borderRight: '1px solid var(--border)',
|
onClick={handleLogin}
|
||||||
}}
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
||||||
>
|
style={{ color: 'var(--accent)' }}
|
||||||
{header}
|
title="Log in"
|
||||||
{content}
|
>
|
||||||
</div>
|
<LogIn size={14} />
|
||||||
)
|
<span>Log in</span>
|
||||||
}
|
</button>
|
||||||
|
)
|
||||||
// Mobile: bottom sheet
|
)}
|
||||||
const sheetHeights = {
|
<button
|
||||||
collapsed: 'h-12',
|
onClick={toggleTheme}
|
||||||
half: 'h-[45vh]',
|
className="p-1.5 rounded"
|
||||||
full: 'h-[85vh]',
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
}
|
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
|
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
return (
|
>
|
||||||
<div
|
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
ref={sheetRef}
|
</button>
|
||||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
</div>
|
||||||
style={{
|
</div>
|
||||||
background: 'var(--bg-raised)',
|
)
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
}}
|
// Desktop: side panel (now 360px to accommodate PlaceCard)
|
||||||
>
|
if (!isMobile) {
|
||||||
{/* Drag handle */}
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex justify-center py-2 cursor-grab"
|
className="absolute top-0 left-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||||
onTouchStart={handleTouchStart}
|
style={{
|
||||||
onTouchEnd={handleTouchEnd}
|
width: '400px',
|
||||||
onClick={() => {
|
background: 'var(--bg-raised)',
|
||||||
if (sheetState === 'collapsed') setSheetState('half')
|
borderRight: '1px solid var(--border)',
|
||||||
else if (sheetState === 'half') setSheetState('full')
|
}}
|
||||||
else setSheetState('half')
|
>
|
||||||
}}
|
{header}
|
||||||
>
|
{content}
|
||||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
</div>
|
||||||
</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))' }}>
|
// Mobile: bottom sheet
|
||||||
{header}
|
const sheetHeights = {
|
||||||
{content}
|
collapsed: 'h-12',
|
||||||
</div>
|
half: 'h-[45vh]',
|
||||||
)}
|
full: 'h-[85vh]',
|
||||||
</div>
|
}
|
||||||
)
|
|
||||||
}
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
283
src/components/Panel.jsx.bak.regressions
Normal file
283
src/components/Panel.jsx.bak.regressions
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
283
src/components/Panel.jsx.bak.uxfix
Normal file
283
src/components/Panel.jsx.bak.uxfix
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
283
src/components/Panel.jsx.bak.uxfix2
Normal file
283
src/components/Panel.jsx.bak.uxfix2
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
283
src/components/Panel.jsx.bak.viewport
Normal file
283
src/components/Panel.jsx.bak.viewport
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState, useRef, useCallback } from "react"
|
import { useEffect, useState, useRef, useCallback } from "react"
|
||||||
import {
|
import {
|
||||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
|
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
|
||||||
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
|
Clock, Phone, Globe, Mail, BookOpen, Info, Trees, GripVertical,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import OpeningHours from "opening_hours"
|
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 = {} }) {
|
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 [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
||||||
const [placeDetails, setPlaceDetails] = useState(null)
|
const [placeDetails, setPlaceDetails] = useState(null)
|
||||||
const [driveTime, setDriveTime] = 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>}
|
{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">
|
<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>
|
<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} />}
|
{copyOpen && <CopyPopover address={address} place={place} onClose={closeCopy} />}
|
||||||
|
|
|
||||||
434
src/components/PlaceCard.jsx.bak.uxfix
Normal file
434
src/components/PlaceCard.jsx.bak.uxfix
Normal 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
|
||||||
434
src/components/PlaceCard.jsx.bak.uxfix2
Normal file
434
src/components/PlaceCard.jsx.bak.uxfix2
Normal 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
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
|
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, LogIn,
|
||||||
Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
|
Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import OpeningHours from 'opening_hours'
|
import OpeningHours from 'opening_hours'
|
||||||
|
|
@ -416,6 +416,7 @@ export default function PlaceDetail() {
|
||||||
const userLocation = useStore((s) => s.userLocation)
|
const userLocation = useStore((s) => s.userLocation)
|
||||||
const contacts = useStore((s) => s.contacts)
|
const contacts = useStore((s) => s.contacts)
|
||||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||||
|
const auth = useStore((s) => s.auth)
|
||||||
|
|
||||||
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
|
@ -742,18 +743,30 @@ export default function PlaceDetail() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{auth.authenticated ? (
|
||||||
onClick={handleSave}
|
<button
|
||||||
className="p-2 rounded-lg"
|
onClick={handleSave}
|
||||||
style={{
|
className="p-2 rounded-lg"
|
||||||
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
|
style={{
|
||||||
color: savedContact ? 'var(--accent)' : 'var(--tan)',
|
background: savedContact ? 'var(--accent-muted)' : 'var(--tan-muted)',
|
||||||
border: '1px solid var(--border)',
|
color: savedContact ? 'var(--accent)' : 'var(--tan)',
|
||||||
}}
|
border: '1px solid var(--border)',
|
||||||
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
|
}}
|
||||||
>
|
aria-label={savedContact ? 'Edit saved contact' : 'Save place'}
|
||||||
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
|
>
|
||||||
</button>
|
<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 */}
|
{/* Copy dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
|
||||||
798
src/components/PlaceDetail.jsx.bak.boundary
Normal file
798
src/components/PlaceDetail.jsx.bak.boundary
Normal 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">·</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
320
src/components/SearchBar.jsx.bak.twoclick
Normal file
320
src/components/SearchBar.jsx.bak.twoclick
Normal 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
|
||||||
117
src/components/StopList.jsx.bak.pending
Normal file
117
src/components/StopList.jsx.bak.pending
Normal 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
537
src/index.css.bak.twoclick
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -109,6 +109,10 @@ export const useStore = create((set, get) => ({
|
||||||
localStorage.removeItem('navi-theme-override')
|
localStorage.removeItem('navi-theme-override')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// ── Auth state ──
|
||||||
|
auth: { authenticated: false, username: null, loaded: false },
|
||||||
|
setAuth: (auth) => set({ auth: { ...auth, loaded: true } }),
|
||||||
|
|
||||||
// ── Contacts ──
|
// ── Contacts ──
|
||||||
contacts: [],
|
contacts: [],
|
||||||
contactsLoaded: false,
|
contactsLoaded: false,
|
||||||
|
|
|
||||||
141
src/store.js.bak.dupstop
Normal file
141
src/store.js.bak.dupstop
Normal 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"
|
||||||
|
})
|
||||||
|
}
|
||||||
139
src/store.js.bak.regressions
Normal file
139
src/store.js.bak.regressions
Normal 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
118
src/store.js.bak.twoclick
Normal 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
133
src/store.js.bak.uxfix
Normal 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"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,10 @@ export const useStore = create((set, get) => ({
|
||||||
setUserLocation: (loc) => set({ userLocation: loc }),
|
setUserLocation: (loc) => set({ userLocation: loc }),
|
||||||
setGeoPermission: (p) => set({ geoPermission: p }),
|
setGeoPermission: (p) => set({ geoPermission: p }),
|
||||||
|
|
||||||
|
// ── Map viewport (for search bias) ──
|
||||||
|
mapCenter: null, // { lat, lon, zoom }
|
||||||
|
setMapCenter: (center) => set({ mapCenter: center }),
|
||||||
|
|
||||||
// ── Mode ──
|
// ── Mode ──
|
||||||
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
|
mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle'
|
||||||
setMode: (mode) => set({ mode }),
|
setMode: (mode) => set({ mode }),
|
||||||
|
|
@ -55,12 +59,15 @@ export const useStore = create((set, get) => ({
|
||||||
clearRoute: () => set({ route: null, routeError: null }),
|
clearRoute: () => set({ route: null, routeError: null }),
|
||||||
|
|
||||||
// ── Place detail ──
|
// ── 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
|
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||||
|
|
||||||
setSelectedPlace: (place) => set({ selectedPlace: place }),
|
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 }),
|
setGpsOrigin: (val) => set({ gpsOrigin: val }),
|
||||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
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 })
|
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
||||||
set({ selectedPlace: null })
|
set({ selectedPlace: null })
|
||||||
} else {
|
} else {
|
||||||
|
// GPS denied, no stops: set pendingDestination only; origin-picker will add both
|
||||||
set({ pendingDestination: place, selectedPlace: null })
|
set({ pendingDestination: place, selectedPlace: null })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -112,3 +120,20 @@ export const useStore = create((set, get) => ({
|
||||||
setEditingContact: (c) => set({ editingContact: c }),
|
setEditingContact: (c) => set({ editingContact: c }),
|
||||||
clearEditingContact: () => set({ editingContact: null }),
|
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"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue