diff --git a/src/App.jsx b/src/App.jsx index 61214cc..ca6d8cf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { + diff --git a/src/api.js b/src/api.js index 526246f..83a4af2 100644 --- a/src/api.js +++ b/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} 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} 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 [] + } +} diff --git a/src/components/ContactList.jsx b/src/components/ContactList.jsx new file mode 100644 index 0000000..3fd8d09 --- /dev/null +++ b/src/components/ContactList.jsx @@ -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 ( +
+

Sign in to use contacts

+
+ ) + } + + 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 ( +
+ {/* Search + add */} +
+ setFilter(e.target.value)} + /> + +
+ + {/* List */} + {filtered.length === 0 ? ( +
+ {contacts.length === 0 ? 'No contacts yet' : 'No matches'} +
+ ) : ( +
+ {filtered.map((c) => ( +
handleClick(c)} + > + + {c.lat != null ? : c.call_sign ? : } + +
+
{c.label}
+
+ {c.name || c.address || c.phone || ''} +
+
+ {c.phone && ( + + + + )} + {c.show_proximity && c.lat != null && ( + + prox + + )} +
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/ContactModal.jsx b/src/components/ContactModal.jsx new file mode 100644 index 0000000..fdb1a0c --- /dev/null +++ b/src/components/ContactModal.jsx @@ -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 ( +
{ if (e.target === e.currentTarget) close() }}> +
+ {/* Header */} +
+

+ {isEdit ? 'Edit Contact' : 'Save Contact'} +

+ +
+ + {/* Label with quick-fill */} +
+ +
+ {['Home', 'Work'].map((l) => ( + + ))} +
+ setField('label', e.target.value)} + placeholder="e.g. Home, Work, Mom, Bug Out" + /> +
+ + {/* Category */} +
+ + setField('category', e.target.value)} + placeholder="family, friend, emergency..." + /> + + {CATEGORIES.map((c) => +
+ + {/* Name + Call Sign */} +
+
+ + setField('name', e.target.value)} /> +
+
+ + setField('call_sign', e.target.value)} /> +
+
+ + {/* Phone + Email */} +
+
+ + setField('phone', e.target.value)} type="tel" /> +
+
+ + setField('email', e.target.value)} type="email" /> +
+
+ + {/* Notes */} +
+ +