mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
Add contacts/phone book UI with search integration
New components: - ContactModal.jsx: Save/edit overlay with form fields and soft delete - ContactList.jsx: Contacts tab with filter, create, and tap-to-navigate Modified: - store.js: Add contacts slice (contacts, activeTab, editingContact) - api.js: Add contacts API functions (fetch, create, update, delete, nearby) - config.js: Add has_contacts fallback flag - Panel.jsx: Routes/Contacts tab bar (only when has_contacts enabled) - PlaceDetail.jsx: Save button opens ContactModal, proximity annotation - SearchBar.jsx: Prepend matching contacts before Photon results - App.jsx: Render ContactModal at top level - index.css: Modal overlay, tab bar, contact list item styles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
03e9780834
commit
3ce860c1e8
10 changed files with 1087 additions and 66 deletions
|
|
@ -6,6 +6,7 @@ import { decodePolyline } from './utils/decode'
|
|||
import MapView from './components/MapView'
|
||||
import Panel from './components/Panel'
|
||||
import PlaceDetail from './components/PlaceDetail'
|
||||
import ContactModal from './components/ContactModal'
|
||||
import LayerControl from './components/LayerControl'
|
||||
import LocateButton from './components/LocateButton'
|
||||
|
||||
|
|
@ -27,14 +28,12 @@ export default function App() {
|
|||
const clearRoute = useStore((s) => s.clearRoute)
|
||||
|
||||
// Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms)
|
||||
// NOTE: userLocation is NOT a dep — read from store inside the callback to avoid re-routing on every GPS update
|
||||
useEffect(() => {
|
||||
if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
|
||||
|
||||
routeDebounceRef.current = setTimeout(async () => {
|
||||
const { userLocation } = useStore.getState()
|
||||
|
||||
// Build effective stop list
|
||||
let effective = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
if (gpsOrigin && geoPermission === 'granted' && userLocation) {
|
||||
effective = [{ lat: userLocation.lat, lon: userLocation.lon }, ...effective]
|
||||
|
|
@ -66,7 +65,7 @@ export default function App() {
|
|||
}
|
||||
}, [stops, mode, gpsOrigin, geoPermission, clearRoute, setRoute, setRouteLoading, setRouteError])
|
||||
|
||||
// Handle maneuver click — fly to that point on the map
|
||||
// Handle maneuver click
|
||||
const handleManeuverClick = useCallback(
|
||||
(maneuver) => {
|
||||
if (!route || !route.legs) return
|
||||
|
|
@ -90,6 +89,7 @@ export default function App() {
|
|||
<MapView ref={mapViewRef} />
|
||||
<Panel onManeuverClick={handleManeuverClick} />
|
||||
<PlaceDetail />
|
||||
<ContactModal />
|
||||
<LayerControl mapRef={mapViewRef} />
|
||||
<LocateButton mapRef={mapViewRef} />
|
||||
</div>
|
||||
|
|
|
|||
101
src/api.js
101
src/api.js
|
|
@ -141,3 +141,104 @@ export async function fetchReverse(lat, lon) {
|
|||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch drive time between two points via Valhalla route.
|
||||
* @param {number} oLat - Origin latitude
|
||||
* @param {number} oLon - Origin longitude
|
||||
* @param {number} dLat - Destination latitude
|
||||
* @param {number} dLon - Destination longitude
|
||||
* @param {AbortSignal} signal - AbortController signal
|
||||
* @returns {Promise<number|null>} Drive time in seconds, or null on error
|
||||
*/
|
||||
export async function fetchDriveTime(oLat, oLon, dLat, dLon, signal) {
|
||||
try {
|
||||
const resp = await fetch(VALHALLA_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
locations: [{ lat: oLat, lon: oLon }, { lat: dLat, lon: dLon }],
|
||||
costing: 'auto',
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
if (!resp.ok) return null
|
||||
const data = await resp.json()
|
||||
return data.trip?.summary?.time ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch enriched place details from the place detail proxy.
|
||||
* @param {string} osmType - N, W, or R
|
||||
* @param {number} osmId - OSM element ID
|
||||
* @param {AbortSignal} signal - AbortController signal for cancellation
|
||||
* @returns {Promise<object|null>} Cleaned place detail object, or null on error
|
||||
*/
|
||||
export async function fetchPlaceDetails(osmType, osmId, signal) {
|
||||
try {
|
||||
const resp = await fetch(`/api/place/${osmType}/${osmId}`, {
|
||||
signal,
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
if (!resp.ok) return null
|
||||
return resp.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contacts API ──
|
||||
|
||||
export async function fetchContacts(signal) {
|
||||
try {
|
||||
const resp = await fetch('/api/contacts', { signal })
|
||||
if (resp.status === 401) return { auth: false }
|
||||
if (!resp.ok) throw new Error(`Contacts error: ${resp.status}`)
|
||||
return resp.json()
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') throw e
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function createContact(data) {
|
||||
const resp = await fetch('/api/contacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (resp.status === 401) return { auth: false }
|
||||
return resp.json().then((d) => ({ ...d, _status: resp.status }))
|
||||
}
|
||||
|
||||
export async function updateContact(id, data) {
|
||||
const resp = await fetch(`/api/contacts/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (resp.status === 401) return { auth: false }
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function deleteContact(id) {
|
||||
const resp = await fetch(`/api/contacts/${id}`, { method: 'DELETE' })
|
||||
if (resp.status === 401) return { auth: false }
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function fetchNearbyContacts(lat, lon, radiusM, signal) {
|
||||
try {
|
||||
const params = new URLSearchParams({ lat: String(lat), lon: String(lon), radius_m: String(radiusM) })
|
||||
const resp = await fetch(`/api/contacts/nearby?${params}`, { signal })
|
||||
if (resp.status === 401) return []
|
||||
if (!resp.ok) return []
|
||||
return resp.json()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
128
src/components/ContactList.jsx
Normal file
128
src/components/ContactList.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Plus, MapPin, User, Phone, Radio } from 'lucide-react'
|
||||
import { useStore } from '../store'
|
||||
import { fetchContacts } from '../api'
|
||||
|
||||
export default function ContactList() {
|
||||
const contacts = useStore((s) => s.contacts)
|
||||
const contactsLoaded = useStore((s) => s.contactsLoaded)
|
||||
const setContacts = useStore((s) => s.setContacts)
|
||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
||||
|
||||
const [filter, setFilter] = useState('')
|
||||
const [authFailed, setAuthFailed] = useState(false)
|
||||
|
||||
const loadContacts = useCallback(async () => {
|
||||
const data = await fetchContacts()
|
||||
if (data?.auth === false) {
|
||||
setAuthFailed(true)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
setContacts(data)
|
||||
setAuthFailed(false)
|
||||
}
|
||||
}, [setContacts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contactsLoaded) loadContacts()
|
||||
}, [contactsLoaded, loadContacts])
|
||||
|
||||
if (authFailed) {
|
||||
return (
|
||||
<div className="mt-4 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Sign in to use contacts</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const q = filter.toLowerCase()
|
||||
const filtered = q
|
||||
? contacts.filter((c) =>
|
||||
(c.label || '').toLowerCase().includes(q) ||
|
||||
(c.name || '').toLowerCase().includes(q) ||
|
||||
(c.call_sign || '').toLowerCase().includes(q) ||
|
||||
(c.phone || '').includes(q)
|
||||
)
|
||||
: contacts
|
||||
|
||||
const handleClick = (c) => {
|
||||
if (c.lat != null && c.lon != null) {
|
||||
setSelectedPlace({
|
||||
lat: c.lat,
|
||||
lon: c.lon,
|
||||
name: c.label,
|
||||
address: c.address || null,
|
||||
type: 'contact',
|
||||
source: 'contacts',
|
||||
matchCode: null,
|
||||
raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
|
||||
})
|
||||
} else {
|
||||
setEditingContact(c)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{/* Search + add */}
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
className="navi-input flex-1"
|
||||
placeholder="Filter contacts..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setEditingContact({})}
|
||||
className="p-2 rounded-lg shrink-0"
|
||||
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
|
||||
title="New contact"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="mt-4 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{contacts.length === 0 ? 'No contacts yet' : 'No matches'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filtered.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="contact-item"
|
||||
onClick={() => handleClick(c)}
|
||||
>
|
||||
<span className="shrink-0" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{c.lat != null ? <MapPin size={14} /> : c.call_sign ? <Radio size={14} /> : <User size={14} />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{c.label}</div>
|
||||
<div className="text-[11px] truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{c.name || c.address || c.phone || ''}
|
||||
</div>
|
||||
</div>
|
||||
{c.phone && (
|
||||
<span className="text-[10px] shrink-0" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Phone size={10} />
|
||||
</span>
|
||||
)}
|
||||
{c.show_proximity && c.lat != null && (
|
||||
<span
|
||||
className="text-[9px] px-1 py-0.5 rounded shrink-0"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||
>
|
||||
prox
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
src/components/ContactModal.jsx
Normal file
242
src/components/ContactModal.jsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { X, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useStore } from '../store'
|
||||
import { createContact, updateContact, deleteContact, fetchContacts } from '../api'
|
||||
|
||||
const CATEGORIES = ['family', 'friend', 'business', 'emergency', 'ham', 'bug-out', 'favorite']
|
||||
|
||||
export default function ContactModal() {
|
||||
const editingContact = useStore((s) => s.editingContact)
|
||||
const clearEditingContact = useStore((s) => s.clearEditingContact)
|
||||
const setContacts = useStore((s) => s.setContacts)
|
||||
|
||||
const [form, setForm] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (editingContact) {
|
||||
setForm({
|
||||
label: editingContact.label || '',
|
||||
name: editingContact.name || '',
|
||||
call_sign: editingContact.call_sign || '',
|
||||
phone: editingContact.phone || '',
|
||||
email: editingContact.email || '',
|
||||
category: editingContact.category || '',
|
||||
notes: editingContact.notes || '',
|
||||
show_proximity: editingContact.show_proximity || false,
|
||||
lat: editingContact.lat ?? null,
|
||||
lon: editingContact.lon ?? null,
|
||||
osm_type: editingContact.osm_type || null,
|
||||
osm_id: editingContact.osm_id || null,
|
||||
address: editingContact.address || '',
|
||||
})
|
||||
}
|
||||
}, [editingContact])
|
||||
|
||||
const close = useCallback(() => clearEditingContact(), [clearEditingContact])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingContact) return
|
||||
const onKey = (e) => { if (e.key === 'Escape') close() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [editingContact, close])
|
||||
|
||||
if (!editingContact) return null
|
||||
|
||||
const isEdit = editingContact.id != null
|
||||
|
||||
const setField = (key, val) => setForm((f) => ({ ...f, [key]: val }))
|
||||
|
||||
const refreshContacts = async () => {
|
||||
const data = await fetchContacts()
|
||||
if (!data?.auth && Array.isArray(data)) setContacts(data)
|
||||
else if (Array.isArray(data)) setContacts(data)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.label?.trim()) {
|
||||
toast.error('Label is required')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = { ...form, label: form.label.trim() }
|
||||
if (payload.show_proximity === false) payload.show_proximity = false
|
||||
const result = isEdit
|
||||
? await updateContact(editingContact.id, payload)
|
||||
: await createContact(payload)
|
||||
if (result?.auth === false) {
|
||||
toast.error('Sign in to save contacts')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
if (result?._status === 409 || result?.error?.includes('Home/Work')) {
|
||||
toast.error('You already have a Home/Work contact')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
toast.success(isEdit ? 'Contact updated' : 'Contact saved')
|
||||
await refreshContacts()
|
||||
close()
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Delete this contact? You can restore it from the dashboard.')) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await deleteContact(editingContact.id)
|
||||
toast('Contact deleted')
|
||||
await refreshContacts()
|
||||
close()
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasGeo = form.lat != null && form.lon != null
|
||||
|
||||
return (
|
||||
<div className="contact-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) close() }}>
|
||||
<div className="contact-modal">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{isEdit ? 'Edit Contact' : 'Save Contact'}
|
||||
</h3>
|
||||
<button onClick={close} className="p-1 rounded" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label with quick-fill */}
|
||||
<div className="mb-3">
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Label *</label>
|
||||
<div className="flex gap-2 mb-1.5">
|
||||
{['Home', 'Work'].map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => setField('label', l)}
|
||||
className="px-2 py-0.5 rounded text-xs"
|
||||
style={{
|
||||
background: form.label === l ? 'var(--accent-muted)' : 'var(--bg-overlay)',
|
||||
color: form.label === l ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="navi-input w-full"
|
||||
value={form.label}
|
||||
onChange={(e) => setField('label', e.target.value)}
|
||||
placeholder="e.g. Home, Work, Mom, Bug Out"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mb-3">
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Category</label>
|
||||
<input
|
||||
className="navi-input w-full"
|
||||
list="contact-categories"
|
||||
value={form.category}
|
||||
onChange={(e) => setField('category', e.target.value)}
|
||||
placeholder="family, friend, emergency..."
|
||||
/>
|
||||
<datalist id="contact-categories">
|
||||
{CATEGORIES.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{/* Name + Call Sign */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Name</label>
|
||||
<input className="navi-input w-full" value={form.name} onChange={(e) => setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Call Sign</label>
|
||||
<input className="navi-input w-full" value={form.call_sign} onChange={(e) => setField('call_sign', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone + Email */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Phone</label>
|
||||
<input className="navi-input w-full" value={form.phone} onChange={(e) => setField('phone', e.target.value)} type="tel" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Email</label>
|
||||
<input className="navi-input w-full" value={form.email} onChange={(e) => setField('email', e.target.value)} type="email" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-3">
|
||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Notes</label>
|
||||
<textarea
|
||||
className="navi-input w-full"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => setField('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show proximity */}
|
||||
<label className="flex items-center gap-2 mb-3 text-xs cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.show_proximity}
|
||||
onChange={(e) => setField('show_proximity', e.target.checked)}
|
||||
className="layer-control-toggle"
|
||||
/>
|
||||
Show "near {form.label || '...'}" on nearby places
|
||||
</label>
|
||||
|
||||
{/* Geo info (read-only) */}
|
||||
{hasGeo && (
|
||||
<div className="mb-3 text-xs font-mono" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{form.lat.toFixed(6)}, {form.lon.toFixed(6)}
|
||||
{form.address && <span className="ml-2">{form.address}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 py-2 px-3 rounded-lg text-xs font-medium"
|
||||
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
|
||||
>
|
||||
{saving ? 'Saving...' : isEdit ? 'Update' : 'Save'}
|
||||
</button>
|
||||
{isEdit && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="p-2 rounded-lg"
|
||||
style={{ background: 'var(--tan-muted)', color: 'var(--status-danger)', border: '1px solid var(--border)' }}
|
||||
title="Delete contact"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
import { useStore } 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 { requestOptimizedRoute } from '../api'
|
||||
|
||||
export default function Panel({ onManeuverClick }) {
|
||||
|
|
@ -24,6 +26,8 @@ export default function Panel({ onManeuverClick }) {
|
|||
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 [isMobile, setIsMobile] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
|
|
@ -31,6 +35,8 @@ export default function Panel({ onManeuverClick }) {
|
|||
const dragStartY = useRef(0)
|
||||
const dragStartState = useRef('half')
|
||||
|
||||
const showContacts = hasFeature('has_contacts')
|
||||
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
|
|
@ -60,7 +66,6 @@ export default function Panel({ onManeuverClick }) {
|
|||
}
|
||||
const data = await requestOptimizedRoute(locations, mode)
|
||||
if (data.trip) {
|
||||
// If GPS origin was prepended, skip it from the result waypoints
|
||||
const wpOrder = hasGpsOrigin && userLocation
|
||||
? (data.trip.locations || []).slice(1)
|
||||
: data.trip.locations
|
||||
|
|
@ -116,7 +121,7 @@ export default function Panel({ onManeuverClick }) {
|
|||
|
||||
const showOptimize = effectiveCount >= 3
|
||||
|
||||
const content = (
|
||||
const routesContent = (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
||||
|
|
@ -153,6 +158,29 @@ export default function Panel({ onManeuverClick }) {
|
|||
</>
|
||||
)
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,187 @@
|
|||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { X, Navigation, Plus, Bookmark, ChevronDown, Copy } from 'lucide-react'
|
||||
import {
|
||||
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
|
||||
Clock, Phone, Globe, Mail, BookOpen, Info,
|
||||
} from 'lucide-react'
|
||||
import OpeningHours from 'opening_hours'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useStore } from '../store'
|
||||
import { fetchElevation } from '../api'
|
||||
import { fetchElevation, fetchPlaceDetails, fetchDriveTime, fetchNearbyContacts } from '../api'
|
||||
import { hasFeature } from '../config'
|
||||
import { buildAddress } from '../utils/place'
|
||||
|
||||
/** Meters to feet */
|
||||
const M_TO_FT = 3.28084
|
||||
|
||||
/** Build display address from raw result data */
|
||||
function buildAddress(place) {
|
||||
if (place.address) return place.address
|
||||
const raw = place.raw || {}
|
||||
const parts = [raw.street, raw.city, raw.state, raw.postcode].filter(Boolean)
|
||||
return parts.join(', ') || null
|
||||
/** 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`
|
||||
}
|
||||
|
||||
/** Copy popover — small dropdown beneath the Copy button */
|
||||
// ── 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)
|
||||
|
||||
// Close on click-outside
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) onClose()
|
||||
|
|
@ -86,6 +248,124 @@ function CopyPopover({ address, selectedPlace, onClose }) {
|
|||
)
|
||||
}
|
||||
|
||||
// ── 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 ────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
|
|
@ -93,12 +373,18 @@ export default function PlaceDetail() {
|
|||
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 [copyForPlace, setCopyForPlace] = useState(null)
|
||||
const [copyOpen, setCopyOpen] = useState(false)
|
||||
const [placeDetails, setPlaceDetails] = useState(null)
|
||||
const [driveTime, setDriveTime] = useState(null)
|
||||
const [nearbyLabel, setNearbyLabel] = useState(null)
|
||||
|
||||
const closeCopy = useCallback(() => setCopyForPlace(null), [])
|
||||
const closeCopy = useCallback(() => setCopyOpen(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
|
|
@ -107,6 +393,8 @@ export default function PlaceDetail() {
|
|||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Close copy popover when place changes
|
||||
useEffect(() => { setCopyOpen(false) }, [selectedPlace])
|
||||
|
||||
// Escape key closes panel
|
||||
useEffect(() => {
|
||||
|
|
@ -130,22 +418,96 @@ export default function PlaceDetail() {
|
|||
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 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])
|
||||
|
||||
// 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
|
||||
|
||||
const placeKey = selectedPlace ? `${selectedPlace.lat},${selectedPlace.lon}` : null
|
||||
if (!selectedPlace) return null
|
||||
|
||||
const address = buildAddress(selectedPlace)
|
||||
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
|
||||
const raw = selectedPlace.raw || {}
|
||||
|
||||
// Check if place is already in stops
|
||||
const existingStopIndex = stops.findIndex(
|
||||
(s) => Math.abs(s.lat - selectedPlace.lat) < 0.00001 && Math.abs(s.lon - selectedPlace.lon) < 0.00001
|
||||
)
|
||||
|
||||
// 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) {
|
||||
|
|
@ -165,7 +527,25 @@ export default function PlaceDetail() {
|
|||
}
|
||||
|
||||
const handleSave = () => {
|
||||
toast('Saved places coming soon')
|
||||
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 = (
|
||||
|
|
@ -183,13 +563,23 @@ export default function PlaceDetail() {
|
|||
{/* Place name */}
|
||||
<div className="pr-8">
|
||||
<h2 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{selectedPlace.name}
|
||||
{selectedPlace.type === 'poi' && selectedPlace.raw?.name
|
||||
? selectedPlace.raw.name
|
||||
: selectedPlace.name}
|
||||
</h2>
|
||||
{selectedPlace.type && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{selectedPlace.type}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
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 */}
|
||||
|
|
@ -208,24 +598,9 @@ export default function PlaceDetail() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Optional extras */}
|
||||
{(raw.opening_hours || raw.website || raw.phone) && (
|
||||
<div className="mt-3 flex flex-col gap-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{raw.opening_hours && <span>{raw.opening_hours}</span>}
|
||||
{raw.website && (
|
||||
<a
|
||||
href={raw.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline truncate"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{raw.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||
</a>
|
||||
)}
|
||||
{raw.phone && <span>{raw.phone}</span>}
|
||||
</div>
|
||||
)}
|
||||
{/* OSM enrichment sections */}
|
||||
{placeDetails === 'loading' && <EnrichmentSkeleton />}
|
||||
{placeDetails && placeDetails !== 'loading' && <EnrichmentSections details={placeDetails} />}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-auto pt-4 flex gap-2">
|
||||
|
|
@ -259,16 +634,20 @@ export default function PlaceDetail() {
|
|||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-lg"
|
||||
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
|
||||
aria-label="Save place"
|
||||
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} />
|
||||
<Bookmark size={14} fill={savedContact ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
{/* Copy dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setCopyForPlace((v) => v === placeKey ? null : placeKey)}
|
||||
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"
|
||||
|
|
@ -276,7 +655,7 @@ export default function PlaceDetail() {
|
|||
<Copy size={14} />
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{copyForPlace === placeKey && (
|
||||
{copyOpen && (
|
||||
<CopyPopover
|
||||
address={address}
|
||||
selectedPlace={selectedPlace}
|
||||
|
|
@ -296,7 +675,8 @@ export default function PlaceDetail() {
|
|||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
maxHeight: '50vh',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X } from 'lucide-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 }) {
|
||||
|
|
@ -10,6 +12,7 @@ function CategoryIcon({ result }) {
|
|||
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} />
|
||||
|
|
@ -39,6 +42,7 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
|
|||
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)
|
||||
|
|
@ -46,6 +50,7 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
|
|||
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)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -64,25 +69,56 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
|
|||
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)
|
||||
setResults(data.results || [])
|
||||
setAutocompleteOpen(data.results?.length > 0)
|
||||
const combined = [...contactResults, ...(data.results || [])]
|
||||
setResults(combined)
|
||||
setAutocompleteOpen(combined.length > 0)
|
||||
setActiveIndex(-1)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
// 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]
|
||||
[setResults, setAutocompleteOpen, setSearchLoading, setAbortController, contacts]
|
||||
)
|
||||
|
||||
const handleChange = (e) => {
|
||||
|
|
@ -102,14 +138,22 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
|
|||
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) {
|
||||
// GPS-denied Directions flow: this result becomes the starting point
|
||||
addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
|
||||
addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
|
||||
clearPendingDestination()
|
||||
toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
|
||||
} else {
|
||||
// Normal flow: open PlaceDetail
|
||||
setSelectedPlace({
|
||||
lat: result.lat,
|
||||
lon: result.lon,
|
||||
|
|
@ -209,27 +253,45 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
|
|||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
{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)' : 'transparent',
|
||||
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: 'var(--text-tertiary)' }}>
|
||||
<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)' }}>
|
||||
{r.name}
|
||||
{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"
|
||||
|
|
@ -240,11 +302,14 @@ const SearchBar = forwardRef(function SearchBar(_, ref) {
|
|||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] mt-0.5 ml-6" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{r.type}{r.confidence && r.confidence !== 'high' ? ` \u00b7 ${r.confidence}` : ''}
|
||||
</div>
|
||||
{secondary && (
|
||||
<div className="text-[11px] mt-0.5 ml-6 truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{secondary}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const FALLBACK_CONFIG = {
|
|||
has_traffic_overlay: false,
|
||||
has_landclass: false,
|
||||
has_address_book_write: false,
|
||||
has_contacts: false,
|
||||
},
|
||||
defaults: {
|
||||
center: [42.5736, -114.6066],
|
||||
|
|
|
|||
|
|
@ -469,3 +469,69 @@ body {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
10
src/store.js
10
src/store.js
|
|
@ -101,4 +101,14 @@ export const useStore = create((set, get) => ({
|
|||
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 }),
|
||||
}))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue