navi/src/components/ContactModal.jsx

420 lines
15 KiB
React
Raw Normal View History

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, 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 || '',
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 || '',
})
setSearchResults([])
setShowDropdown(false)
}
}, [editingContact])
// Auto-populate address from reverse geocode when lat/lon exist but address is empty
useEffect(() => {
if (!editingContact) return
const hasGeo = form.lat != null && form.lon != null
const addressEmpty = !form.address || form.address.trim() === ''
if (hasGeo && addressEmpty) {
let cancelled = false
fetchReverse(form.lat, form.lon).then((place) => {
if (!cancelled && place) {
const addr = place.address || place.name || ''
if (addr) {
setForm((f) => ({ ...f, address: addr }))
}
}
})
return () => { cancelled = true }
}
}, [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') {
if (showDropdown) {
setShowDropdown(false)
} else {
close()
}
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [editingContact, close, showDropdown])
if (!editingContact) return null
const isEdit = editingContact.id != null
const hasGeo = form.lat != null && form.lon != null
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)
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() }
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)
: 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)
}
}
// 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 (
<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>
{/* Address with geocode search */}
<div className="mb-3 relative">
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Address</label>
<input
ref={inputRef}
className="navi-input w-full"
value={form.address}
onChange={handleAddressChange}
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>
{/* Location display + Set on map button */}
<div className="mb-3">
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Location</label>
<div className="flex items-center gap-2">
{hasGeo ? (
<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 }} />
<span>{form.lat.toFixed(6)}, {form.lon.toFixed(6)}</span>
</div>
) : (
<div className="text-xs flex-1" style={{ color: 'var(--text-tertiary)' }}>
No location set
</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>
{/* 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>
{/* 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>
)
}