diff --git a/src/components/ContactModal.jsx b/src/components/ContactModal.jsx index dc6e07e..d6e73ed 100644 --- a/src/components/ContactModal.jsx +++ b/src/components/ContactModal.jsx @@ -1,22 +1,31 @@ -import { useState, useEffect, useCallback } from 'react' -import { X, Trash2, MapPin } from 'lucide-react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { X, Trash2, MapPin, Crosshair } from 'lucide-react' import toast from 'react-hot-toast' 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'] export default function ContactModal() { const editingContact = useStore((s) => s.editingContact) const clearEditingContact = useStore((s) => s.clearEditingContact) + const setPickingLocationFor = useStore((s) => s.setPickingLocationFor) const setContacts = useStore((s) => s.setContacts) const [form, setForm] = useState({}) 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(() => { if (editingContact) { setForm({ + id: editingContact.id, label: editingContact.label || '', name: editingContact.name || '', call_sign: editingContact.call_sign || '', @@ -31,6 +40,8 @@ export default function ContactModal() { osm_id: editingContact.osm_id || null, address: editingContact.address || '', }) + setSearchResults([]) + setShowDropdown(false) } }, [editingContact]) @@ -53,14 +64,29 @@ export default function ContactModal() { } }, [editingContact, form.lat, form.lon]) + // Cleanup debounce on unmount + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, []) + const close = useCallback(() => clearEditingContact(), [clearEditingContact]) useEffect(() => { 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) return () => document.removeEventListener('keydown', onKey) - }, [editingContact, close]) + }, [editingContact, close, showDropdown]) if (!editingContact) return null @@ -69,6 +95,57 @@ export default function ContactModal() { 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 data = await fetchContacts() if (!data?.auth && Array.isArray(data)) setContacts(data) @@ -83,6 +160,7 @@ export default function ContactModal() { setSaving(true) try { 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 const result = isEdit ? 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 (
{ if (e.target === e.currentTarget) close() }}>
@@ -163,32 +250,87 @@ export default function ContactModal() { />
- {/* Address */} -
+ {/* Address with geocode search */} +
setField('address', e.target.value)} - placeholder="Street address, city, state..." + onChange={handleAddressChange} + onFocus={() => searchResults.length > 0 && setShowDropdown(true)} + onBlur={() => setTimeout(() => setShowDropdown(false), 200)} + placeholder="Type to search or enter address..." /> -
- - {/* Location */} -
- - {hasGeo ? ( -
- - {form.lat.toFixed(6)}, {form.lon.toFixed(6)} -
- ) : ( -
- No location — save from a place card to attach coordinates + {searchLoading && ( +
...
+ )} + {/* Dropdown results */} + {showDropdown && searchResults.length > 0 && ( +
+ {searchResults.map((r, i) => ( + + ))}
)}
+ {/* Location display + Set on map button */} +
+ +
+ {hasGeo ? ( +
+ + {form.lat.toFixed(6)}, {form.lon.toFixed(6)} +
+ ) : ( +
+ No location set +
+ )} + +
+
+ {/* Category */}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index f661d42..3f70f41 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -665,6 +665,9 @@ const MapView = forwardRef(function MapView(_, ref) { const geoPermission = useStore((s) => s.geoPermission) const setSheetState = useStore((s) => s.setSheetState) 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 const [zoomLevel, setZoomLevel] = useState(10) @@ -1021,6 +1024,35 @@ const MapView = forwardRef(function MapView(_, ref) { 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 marker = store.clickMarker @@ -1646,6 +1678,36 @@ const MapView = forwardRef(function MapView(_, ref) { return () => window.removeEventListener("keydown", handleKeyDown) }, [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 useEffect(() => { const map = mapInstance.current diff --git a/src/store.js b/src/store.js index 86a78d7..91fd64d 100644 --- a/src/store.js +++ b/src/store.js @@ -118,11 +118,14 @@ export const useStore = create((set, get) => ({ contactsLoaded: false, activeTab: 'routes', // 'routes' | 'contacts' 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 }), setActiveTab: (tab) => set({ activeTab: tab }), setEditingContact: (c) => set({ editingContact: c }), clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), })) // ── Panel state selector ──