mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
feat: geocode search + map pick for contact location
This commit is contained in:
parent
a7519a3aab
commit
99bd2218a4
3 changed files with 227 additions and 20 deletions
|
|
@ -1,22 +1,31 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { X, Trash2, MapPin } from 'lucide-react'
|
import { X, Trash2, MapPin, Crosshair } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { createContact, updateContact, deleteContact, fetchContacts, fetchReverse } from '../api'
|
import { createContact, updateContact, deleteContact, fetchContacts, fetchReverse, searchGeocode } from '../api'
|
||||||
|
|
||||||
const CATEGORIES = ['family', 'friend', 'business', 'emergency', 'ham', 'bug-out', 'favorite']
|
const CATEGORIES = ['family', 'friend', 'business', 'emergency', 'ham', 'bug-out', 'favorite']
|
||||||
|
|
||||||
export default function ContactModal() {
|
export default function ContactModal() {
|
||||||
const editingContact = useStore((s) => s.editingContact)
|
const editingContact = useStore((s) => s.editingContact)
|
||||||
const clearEditingContact = useStore((s) => s.clearEditingContact)
|
const clearEditingContact = useStore((s) => s.clearEditingContact)
|
||||||
|
const setPickingLocationFor = useStore((s) => s.setPickingLocationFor)
|
||||||
const setContacts = useStore((s) => s.setContacts)
|
const setContacts = useStore((s) => s.setContacts)
|
||||||
|
|
||||||
const [form, setForm] = useState({})
|
const [form, setForm] = useState({})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Geocode search state
|
||||||
|
const [searchResults, setSearchResults] = useState([])
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false)
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
const debounceRef = useRef(null)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingContact) {
|
if (editingContact) {
|
||||||
setForm({
|
setForm({
|
||||||
|
id: editingContact.id,
|
||||||
label: editingContact.label || '',
|
label: editingContact.label || '',
|
||||||
name: editingContact.name || '',
|
name: editingContact.name || '',
|
||||||
call_sign: editingContact.call_sign || '',
|
call_sign: editingContact.call_sign || '',
|
||||||
|
|
@ -31,6 +40,8 @@ export default function ContactModal() {
|
||||||
osm_id: editingContact.osm_id || null,
|
osm_id: editingContact.osm_id || null,
|
||||||
address: editingContact.address || '',
|
address: editingContact.address || '',
|
||||||
})
|
})
|
||||||
|
setSearchResults([])
|
||||||
|
setShowDropdown(false)
|
||||||
}
|
}
|
||||||
}, [editingContact])
|
}, [editingContact])
|
||||||
|
|
||||||
|
|
@ -53,14 +64,29 @@ export default function ContactModal() {
|
||||||
}
|
}
|
||||||
}, [editingContact, form.lat, form.lon])
|
}, [editingContact, form.lat, form.lon])
|
||||||
|
|
||||||
|
// Cleanup debounce on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const close = useCallback(() => clearEditingContact(), [clearEditingContact])
|
const close = useCallback(() => clearEditingContact(), [clearEditingContact])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editingContact) return
|
if (!editingContact) return
|
||||||
const onKey = (e) => { if (e.key === 'Escape') close() }
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (showDropdown) {
|
||||||
|
setShowDropdown(false)
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [editingContact, close])
|
}, [editingContact, close, showDropdown])
|
||||||
|
|
||||||
if (!editingContact) return null
|
if (!editingContact) return null
|
||||||
|
|
||||||
|
|
@ -69,6 +95,57 @@ export default function ContactModal() {
|
||||||
|
|
||||||
const setField = (key, val) => setForm((f) => ({ ...f, [key]: val }))
|
const setField = (key, val) => setForm((f) => ({ ...f, [key]: val }))
|
||||||
|
|
||||||
|
// Handle address input change with debounced geocode search
|
||||||
|
const handleAddressChange = (e) => {
|
||||||
|
const query = e.target.value
|
||||||
|
setField('address', query)
|
||||||
|
|
||||||
|
// Clear previous debounce
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
|
||||||
|
if (!query || query.length < 3) {
|
||||||
|
setSearchResults([])
|
||||||
|
setShowDropdown(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setSearchLoading(true)
|
||||||
|
try {
|
||||||
|
const results = await searchGeocode(query, 5)
|
||||||
|
setSearchResults(results || [])
|
||||||
|
setShowDropdown(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Geocode search error:', err)
|
||||||
|
setSearchResults([])
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle selecting a geocode result
|
||||||
|
const handleSelectResult = (result) => {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
address: result.display_name || result.name || '',
|
||||||
|
lat: result.lat,
|
||||||
|
lon: result.lon,
|
||||||
|
osm_type: result.osm_type || null,
|
||||||
|
osm_id: result.osm_id || null,
|
||||||
|
}))
|
||||||
|
setShowDropdown(false)
|
||||||
|
setSearchResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "Set on map" button
|
||||||
|
const handleSetOnMap = () => {
|
||||||
|
// Save current form state to store for map pick mode
|
||||||
|
setPickingLocationFor({ ...form })
|
||||||
|
clearEditingContact()
|
||||||
|
toast('Click the map to set location', { icon: '📍', duration: 3000 })
|
||||||
|
}
|
||||||
|
|
||||||
const refreshContacts = async () => {
|
const refreshContacts = async () => {
|
||||||
const data = await fetchContacts()
|
const data = await fetchContacts()
|
||||||
if (!data?.auth && Array.isArray(data)) setContacts(data)
|
if (!data?.auth && Array.isArray(data)) setContacts(data)
|
||||||
|
|
@ -83,6 +160,7 @@ export default function ContactModal() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const payload = { ...form, label: form.label.trim() }
|
const payload = { ...form, label: form.label.trim() }
|
||||||
|
delete payload.id // Don't send id in payload
|
||||||
if (payload.show_proximity === false) payload.show_proximity = false
|
if (payload.show_proximity === false) payload.show_proximity = false
|
||||||
const result = isEdit
|
const result = isEdit
|
||||||
? await updateContact(editingContact.id, payload)
|
? await updateContact(editingContact.id, payload)
|
||||||
|
|
@ -122,6 +200,15 @@ export default function ContactModal() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format result for display
|
||||||
|
const formatResult = (r) => {
|
||||||
|
const parts = []
|
||||||
|
if (r.name) parts.push(r.name)
|
||||||
|
if (r.address?.city) parts.push(r.address.city)
|
||||||
|
if (r.address?.state) parts.push(r.address.state)
|
||||||
|
return parts.length > 0 ? parts.join(', ') : r.display_name || 'Unknown location'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contact-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) close() }}>
|
<div className="contact-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) close() }}>
|
||||||
<div className="contact-modal">
|
<div className="contact-modal">
|
||||||
|
|
@ -163,30 +250,85 @@ export default function ContactModal() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address */}
|
{/* Address with geocode search */}
|
||||||
<div className="mb-3">
|
<div className="mb-3 relative">
|
||||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Address</label>
|
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Address</label>
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
className="navi-input w-full"
|
className="navi-input w-full"
|
||||||
value={form.address}
|
value={form.address}
|
||||||
onChange={(e) => setField('address', e.target.value)}
|
onChange={handleAddressChange}
|
||||||
placeholder="Street address, city, state..."
|
onFocus={() => searchResults.length > 0 && setShowDropdown(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
|
||||||
|
placeholder="Type to search or enter address..."
|
||||||
/>
|
/>
|
||||||
|
{searchLoading && (
|
||||||
|
<div className="absolute right-2 top-7 text-xs" style={{ color: 'var(--text-tertiary)' }}>...</div>
|
||||||
|
)}
|
||||||
|
{/* Dropdown results */}
|
||||||
|
{showDropdown && searchResults.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 mt-1 rounded-lg overflow-hidden z-50"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-raised)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchResults.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 text-xs hover:opacity-80"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
borderBottom: i < searchResults.length - 1 ? '1px solid var(--border-subtle)' : 'none',
|
||||||
|
}}
|
||||||
|
onMouseDown={() => handleSelectResult(r)}
|
||||||
|
>
|
||||||
|
<div className="truncate">{formatResult(r)}</div>
|
||||||
|
{r.display_name && r.display_name !== formatResult(r) && (
|
||||||
|
<div className="truncate text-[10px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{r.display_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location display + Set on map button */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Location</label>
|
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Location</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{hasGeo ? (
|
{hasGeo ? (
|
||||||
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
|
<div className="flex items-center gap-2 text-xs flex-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
<MapPin size={12} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
<MapPin size={12} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||||
<span>{form.lat.toFixed(6)}, {form.lon.toFixed(6)}</span>
|
<span>{form.lat.toFixed(6)}, {form.lon.toFixed(6)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
<div className="text-xs flex-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
No location — save from a place card to attach coordinates
|
No location set
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSetOnMap}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-overlay)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Crosshair size={12} />
|
||||||
|
Set on map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
|
|
|
||||||
|
|
@ -665,6 +665,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
const geoPermission = useStore((s) => s.geoPermission)
|
const geoPermission = useStore((s) => s.geoPermission)
|
||||||
const setSheetState = useStore((s) => s.setSheetState)
|
const setSheetState = useStore((s) => s.setSheetState)
|
||||||
const setMapCenter = useStore((s) => s.setMapCenter)
|
const setMapCenter = useStore((s) => s.setMapCenter)
|
||||||
|
const pickingLocationFor = useStore((s) => s.pickingLocationFor)
|
||||||
|
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||||
|
const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor)
|
||||||
|
|
||||||
// Zoom level indicator state
|
// Zoom level indicator state
|
||||||
const [zoomLevel, setZoomLevel] = useState(10)
|
const [zoomLevel, setZoomLevel] = useState(10)
|
||||||
|
|
@ -1021,6 +1024,35 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle location pick mode for contacts
|
||||||
|
const pickState = useStore.getState().pickingLocationFor
|
||||||
|
if (pickState) {
|
||||||
|
const { lng, lat } = e.lngLat
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
// Reverse geocode for address
|
||||||
|
fetchReverse(lat, lng).then((place) => {
|
||||||
|
const addr = place?.address || place?.name || ''
|
||||||
|
// Rebuild form data with new location
|
||||||
|
useStore.getState().setEditingContact({
|
||||||
|
...pickState,
|
||||||
|
lat,
|
||||||
|
lon: lng,
|
||||||
|
address: addr || pickState.address || '',
|
||||||
|
})
|
||||||
|
useStore.getState().clearPickingLocationFor()
|
||||||
|
}).catch(() => {
|
||||||
|
// Even if reverse geocode fails, set the location
|
||||||
|
useStore.getState().setEditingContact({
|
||||||
|
...pickState,
|
||||||
|
lat,
|
||||||
|
lon: lng,
|
||||||
|
})
|
||||||
|
useStore.getState().clearPickingLocationFor()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const store = useStore.getState()
|
const store = useStore.getState()
|
||||||
const marker = store.clickMarker
|
const marker = store.clickMarker
|
||||||
|
|
@ -1646,6 +1678,36 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
}, [measuring.active])
|
}, [measuring.active])
|
||||||
|
|
||||||
|
// Handle location pick mode for contacts
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapInstance.current
|
||||||
|
if (!map) return
|
||||||
|
if (pickingLocationFor) {
|
||||||
|
map.getCanvas().style.cursor = 'crosshair'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (map && !measuring.active) {
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pickingLocationFor, measuring.active])
|
||||||
|
|
||||||
|
// ESC key handler for location pick mode
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape' && pickingLocationFor) {
|
||||||
|
// Cancel pick mode, reopen modal with original form data
|
||||||
|
const map = mapInstance.current
|
||||||
|
if (map) map.getCanvas().style.cursor = ''
|
||||||
|
setEditingContact(pickingLocationFor)
|
||||||
|
clearPickingLocationFor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [pickingLocationFor, setEditingContact, clearPickingLocationFor])
|
||||||
|
|
||||||
|
|
||||||
// Track zoom level for indicator
|
// Track zoom level for indicator
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapInstance.current
|
const map = mapInstance.current
|
||||||
|
|
|
||||||
|
|
@ -118,11 +118,14 @@ export const useStore = create((set, get) => ({
|
||||||
contactsLoaded: false,
|
contactsLoaded: false,
|
||||||
activeTab: 'routes', // 'routes' | 'contacts'
|
activeTab: 'routes', // 'routes' | 'contacts'
|
||||||
editingContact: null, // null=closed, {}=new, {id:N}=edit
|
editingContact: null, // null=closed, {}=new, {id:N}=edit
|
||||||
|
pickingLocationFor: null, // form data while user picks location on map
|
||||||
|
|
||||||
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
|
setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
|
||||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||||
setEditingContact: (c) => set({ editingContact: c }),
|
setEditingContact: (c) => set({ editingContact: c }),
|
||||||
clearEditingContact: () => set({ editingContact: null }),
|
clearEditingContact: () => set({ editingContact: null }),
|
||||||
|
setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }),
|
||||||
|
clearPickingLocationFor: () => set({ pickingLocationFor: null }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ── Panel state selector ──
|
// ── Panel state selector ──
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue