feat: add editable address field and location display to contacts

This commit is contained in:
Matt 2026-04-28 22:57:30 +00:00
commit a7519a3aab

View file

@ -1,242 +1,278 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { X, Trash2 } from 'lucide-react' import { X, Trash2, MapPin } 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 } from '../api' import { createContact, updateContact, deleteContact, fetchContacts, fetchReverse } 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 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)
useEffect(() => { useEffect(() => {
if (editingContact) { if (editingContact) {
setForm({ setForm({
label: editingContact.label || '', label: editingContact.label || '',
name: editingContact.name || '', name: editingContact.name || '',
call_sign: editingContact.call_sign || '', call_sign: editingContact.call_sign || '',
phone: editingContact.phone || '', phone: editingContact.phone || '',
email: editingContact.email || '', email: editingContact.email || '',
category: editingContact.category || '', category: editingContact.category || '',
notes: editingContact.notes || '', notes: editingContact.notes || '',
show_proximity: editingContact.show_proximity || false, show_proximity: editingContact.show_proximity || false,
lat: editingContact.lat ?? null, lat: editingContact.lat ?? null,
lon: editingContact.lon ?? null, lon: editingContact.lon ?? null,
osm_type: editingContact.osm_type || null, osm_type: editingContact.osm_type || null,
osm_id: editingContact.osm_id || null, osm_id: editingContact.osm_id || null,
address: editingContact.address || '', address: editingContact.address || '',
}) })
} }
}, [editingContact]) }, [editingContact])
const close = useCallback(() => clearEditingContact(), [clearEditingContact]) // Auto-populate address from reverse geocode when lat/lon exist but address is empty
useEffect(() => {
useEffect(() => { if (!editingContact) return
if (!editingContact) return const hasGeo = form.lat != null && form.lon != null
const onKey = (e) => { if (e.key === 'Escape') close() } const addressEmpty = !form.address || form.address.trim() === ''
document.addEventListener('keydown', onKey) if (hasGeo && addressEmpty) {
return () => document.removeEventListener('keydown', onKey) let cancelled = false
}, [editingContact, close]) fetchReverse(form.lat, form.lon).then((place) => {
if (!cancelled && place) {
if (!editingContact) return null const addr = place.address || place.name || ''
if (addr) {
const isEdit = editingContact.id != null setForm((f) => ({ ...f, address: addr }))
}
const setField = (key, val) => setForm((f) => ({ ...f, [key]: val })) }
})
const refreshContacts = async () => { return () => { cancelled = true }
const data = await fetchContacts() }
if (!data?.auth && Array.isArray(data)) setContacts(data) }, [editingContact, form.lat, form.lon])
else if (Array.isArray(data)) setContacts(data)
} const close = useCallback(() => clearEditingContact(), [clearEditingContact])
const handleSave = async () => { useEffect(() => {
if (!form.label?.trim()) { if (!editingContact) return
toast.error('Label is required') const onKey = (e) => { if (e.key === 'Escape') close() }
return document.addEventListener('keydown', onKey)
} return () => document.removeEventListener('keydown', onKey)
setSaving(true) }, [editingContact, close])
try {
const payload = { ...form, label: form.label.trim() } if (!editingContact) return null
if (payload.show_proximity === false) payload.show_proximity = false
const result = isEdit const isEdit = editingContact.id != null
? await updateContact(editingContact.id, payload) const hasGeo = form.lat != null && form.lon != null
: await createContact(payload)
if (result?.auth === false) { const setField = (key, val) => setForm((f) => ({ ...f, [key]: val }))
toast.error('Sign in to save contacts')
setSaving(false) const refreshContacts = async () => {
return const data = await fetchContacts()
} if (!data?.auth && Array.isArray(data)) setContacts(data)
if (result?._status === 409 || result?.error?.includes('Home/Work')) { else if (Array.isArray(data)) setContacts(data)
toast.error('You already have a Home/Work contact') }
setSaving(false)
return const handleSave = async () => {
} if (!form.label?.trim()) {
toast.success(isEdit ? 'Contact updated' : 'Contact saved') toast.error('Label is required')
await refreshContacts() return
close() }
} catch (e) { setSaving(true)
toast.error(e.message) try {
} finally { const payload = { ...form, label: form.label.trim() }
setSaving(false) if (payload.show_proximity === false) payload.show_proximity = false
} const result = isEdit
} ? await updateContact(editingContact.id, payload)
: await createContact(payload)
const handleDelete = async () => { if (result?.auth === false) {
if (!confirm('Delete this contact? You can restore it from the dashboard.')) return toast.error('Sign in to save contacts')
setSaving(true) setSaving(false)
try { return
await deleteContact(editingContact.id) }
toast('Contact deleted') if (result?._status === 409 || result?.error?.includes('Home/Work')) {
await refreshContacts() toast.error('You already have a Home/Work contact')
close() setSaving(false)
} catch (e) { return
toast.error(e.message) }
} finally { toast.success(isEdit ? 'Contact updated' : 'Contact saved')
setSaving(false) await refreshContacts()
} close()
} } catch (e) {
toast.error(e.message)
const hasGeo = form.lat != null && form.lon != null } finally {
setSaving(false)
return ( }
<div className="contact-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) close() }}> }
<div className="contact-modal">
{/* Header */} const handleDelete = async () => {
<div className="flex items-center justify-between mb-4"> if (!confirm('Delete this contact? You can restore it from the dashboard.')) return
<h3 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}> setSaving(true)
{isEdit ? 'Edit Contact' : 'Save Contact'} try {
</h3> await deleteContact(editingContact.id)
<button onClick={close} className="p-1 rounded" style={{ color: 'var(--text-tertiary)' }}> toast('Contact deleted')
<X size={18} /> await refreshContacts()
</button> close()
</div> } catch (e) {
toast.error(e.message)
{/* Label with quick-fill */} } finally {
<div className="mb-3"> setSaving(false)
<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 return (
key={l} <div className="contact-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) close() }}>
type="button" <div className="contact-modal">
onClick={() => setField('label', l)} {/* Header */}
className="px-2 py-0.5 rounded text-xs" <div className="flex items-center justify-between mb-4">
style={{ <h3 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}>
background: form.label === l ? 'var(--accent-muted)' : 'var(--bg-overlay)', {isEdit ? 'Edit Contact' : 'Save Contact'}
color: form.label === l ? 'var(--accent)' : 'var(--text-secondary)', </h3>
border: '1px solid var(--border-subtle)', <button onClick={close} className="p-1 rounded" style={{ color: 'var(--text-tertiary)' }}>
}} <X size={18} />
> </button>
{l} </div>
</button>
))} {/* Label with quick-fill */}
</div> <div className="mb-3">
<input <label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Label *</label>
className="navi-input w-full" <div className="flex gap-2 mb-1.5">
value={form.label} {['Home', 'Work'].map((l) => (
onChange={(e) => setField('label', e.target.value)} <button
placeholder="e.g. Home, Work, Mom, Bug Out" key={l}
/> type="button"
</div> onClick={() => setField('label', l)}
className="px-2 py-0.5 rounded text-xs"
{/* Category */} style={{
<div className="mb-3"> background: form.label === l ? 'var(--accent-muted)' : 'var(--bg-overlay)',
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Category</label> color: form.label === l ? 'var(--accent)' : 'var(--text-secondary)',
<input border: '1px solid var(--border-subtle)',
className="navi-input w-full" }}
list="contact-categories" >
value={form.category} {l}
onChange={(e) => setField('category', e.target.value)} </button>
placeholder="family, friend, emergency..." ))}
/> </div>
<datalist id="contact-categories"> <input
{CATEGORIES.map((c) => <option key={c} value={c} />)} className="navi-input w-full"
</datalist> value={form.label}
</div> onChange={(e) => setField('label', e.target.value)}
placeholder="e.g. Home, Work, Mom, Bug Out"
{/* Name + Call Sign */} />
<div className="flex gap-2 mb-3"> </div>
<div className="flex-1">
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Name</label> {/* Address */}
<input className="navi-input w-full" value={form.name} onChange={(e) => setField('name', e.target.value)} /> <div className="mb-3">
</div> <label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Address</label>
<div className="w-24"> <input
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Call Sign</label> className="navi-input w-full"
<input className="navi-input w-full" value={form.call_sign} onChange={(e) => setField('call_sign', e.target.value)} /> value={form.address}
</div> onChange={(e) => setField('address', e.target.value)}
</div> placeholder="Street address, city, state..."
/>
{/* Phone + Email */} </div>
<div className="flex gap-2 mb-3">
<div className="flex-1"> {/* Location */}
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Phone</label> <div className="mb-3">
<input className="navi-input w-full" value={form.phone} onChange={(e) => setField('phone', e.target.value)} type="tel" /> <label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Location</label>
</div> {hasGeo ? (
<div className="flex-1"> <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-primary)' }}>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Email</label> <MapPin size={12} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<input className="navi-input w-full" value={form.email} onChange={(e) => setField('email', e.target.value)} type="email" /> <span>{form.lat.toFixed(6)}, {form.lon.toFixed(6)}</span>
</div> </div>
</div> ) : (
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{/* Notes */} No location save from a place card to attach coordinates
<div className="mb-3"> </div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Notes</label> )}
<textarea </div>
className="navi-input w-full"
rows={2} {/* Category */}
value={form.notes} <div className="mb-3">
onChange={(e) => setField('notes', e.target.value)} <label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Category</label>
/> <input
</div> className="navi-input w-full"
list="contact-categories"
{/* Show proximity */} value={form.category}
<label className="flex items-center gap-2 mb-3 text-xs cursor-pointer" style={{ color: 'var(--text-secondary)' }}> onChange={(e) => setField('category', e.target.value)}
<input placeholder="family, friend, emergency..."
type="checkbox" />
checked={form.show_proximity} <datalist id="contact-categories">
onChange={(e) => setField('show_proximity', e.target.checked)} {CATEGORIES.map((c) => <option key={c} value={c} />)}
className="layer-control-toggle" </datalist>
/> </div>
Show "near {form.label || '...'}" on nearby places
</label> {/* Name + Call Sign */}
<div className="flex gap-2 mb-3">
{/* Geo info (read-only) */} <div className="flex-1">
{hasGeo && ( <label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Name</label>
<div className="mb-3 text-xs font-mono" style={{ color: 'var(--text-tertiary)' }}> <input className="navi-input w-full" value={form.name} onChange={(e) => setField('name', e.target.value)} />
{form.lat.toFixed(6)}, {form.lon.toFixed(6)} </div>
{form.address && <span className="ml-2">{form.address}</span>} <div className="w-24">
</div> <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>
{/* Actions */} </div>
<div className="flex gap-2 mt-4">
<button {/* Phone + Email */}
onClick={handleSave} <div className="flex gap-2 mb-3">
disabled={saving} <div className="flex-1">
className="flex-1 py-2 px-3 rounded-lg text-xs font-medium" <label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Phone</label>
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }} <input className="navi-input w-full" value={form.phone} onChange={(e) => setField('phone', e.target.value)} type="tel" />
> </div>
{saving ? 'Saving...' : isEdit ? 'Update' : 'Save'} <div className="flex-1">
</button> <label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Email</label>
{isEdit && ( <input className="navi-input w-full" value={form.email} onChange={(e) => setField('email', e.target.value)} type="email" />
<button </div>
onClick={handleDelete} </div>
disabled={saving}
className="p-2 rounded-lg" {/* Notes */}
style={{ background: 'var(--tan-muted)', color: 'var(--status-danger)', border: '1px solid var(--border)' }} <div className="mb-3">
title="Delete contact" <label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Notes</label>
> <textarea
<Trash2 size={14} /> className="navi-input w-full"
</button> rows={2}
)} value={form.notes}
</div> onChange={(e) => setField('notes', e.target.value)}
</div> />
</div> </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>
)
}