diff --git a/src/components/ContactList.jsx b/src/components/ContactList.jsx index 432b33b..6a75dee 100644 --- a/src/components/ContactList.jsx +++ b/src/components/ContactList.jsx @@ -1,138 +1,279 @@ -import { useEffect, useState, useCallback } from 'react' -import { Plus, MapPin, User, Phone, Radio, LogIn } 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 auth = useStore((s) => s.auth) - - const [filter, setFilter] = useState('') - - const loadContacts = useCallback(async () => { - // Skip fetch entirely if not authenticated - if (!auth.authenticated) return - const data = await fetchContacts() - if (Array.isArray(data)) { - setContacts(data) - } - }, [setContacts, auth.authenticated]) - - useEffect(() => { - if (auth.loaded && auth.authenticated && !contactsLoaded) { - loadContacts() - } - }, [auth.loaded, auth.authenticated, contactsLoaded, loadContacts]) - - // Show login prompt if not authenticated - if (auth.loaded && !auth.authenticated) { - return ( -
-

- Sign in to save and sync your 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 - - )} -
- ))} -
- )} -
- ) -} +import { useEffect, useState, useCallback, useRef } from 'react' +import { Plus, MapPin, User, Phone, Radio, LogIn, MoreVertical, Navigation, Eye, Pencil, Trash2 } from 'lucide-react' +import toast from 'react-hot-toast' +import { useStore } from '../store' +import { fetchContacts, deleteContact } 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 setClickMarker = useStore((s) => s.setClickMarker) + const startDirections = useStore((s) => s.startDirections) + const setActiveTab = useStore((s) => s.setActiveTab) + const auth = useStore((s) => s.auth) + + const [filter, setFilter] = useState('') + const [menuOpen, setMenuOpen] = useState(null) // contact id or null + const menuRef = useRef(null) + + const loadContacts = useCallback(async () => { + if (!auth.authenticated) return + const data = await fetchContacts() + if (Array.isArray(data)) { + setContacts(data) + } + }, [setContacts, auth.authenticated]) + + useEffect(() => { + if (auth.loaded && auth.authenticated && !contactsLoaded) { + loadContacts() + } + }, [auth.loaded, auth.authenticated, contactsLoaded, loadContacts]) + + // Close menu on outside click or Escape + useEffect(() => { + if (!menuOpen) return + const handleClick = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) { + setMenuOpen(null) + } + } + const handleKey = (e) => { + if (e.key === 'Escape') setMenuOpen(null) + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + } + }, [menuOpen]) + + // Show login prompt if not authenticated + if (auth.loaded && !auth.authenticated) { + return ( +
+

+ Sign in to save and sync your 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) + } + } + + const handleMenuClick = (e, contactId) => { + e.stopPropagation() + setMenuOpen(menuOpen === contactId ? null : contactId) + } + + const handleDirections = (c) => { + setMenuOpen(null) + startDirections({ lat: c.lat, lon: c.lon, name: c.label }) + } + + const handleViewOnMap = (c) => { + setMenuOpen(null) + // Set click marker at location + setClickMarker({ lat: c.lat, lon: c.lon }) + // Set selected place to trigger map fly and place card + 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 }, + }) + // Switch to routes tab to close contacts panel + setActiveTab('routes') + } + + const handleEdit = (c) => { + setMenuOpen(null) + setEditingContact(c) + } + + const handleDelete = async (c) => { + setMenuOpen(null) + if (!confirm(`Delete "${c.label}"? You can restore it from the dashboard.`)) return + try { + const result = await deleteContact(c.id) + if (result?.auth === false) { + toast.error('Sign in to delete contacts') + return + } + toast.success('Contact deleted') + await loadContacts() + } catch (e) { + toast.error(e.message) + } + } + + return ( +
+ {/* Search + add */} +
+ setFilter(e.target.value)} + /> + +
+ + {/* List */} + {filtered.length === 0 ? ( +
+ {contacts.length === 0 ? 'No contacts yet' : 'No matches'} +
+ ) : ( +
+ {filtered.map((c) => { + const hasLocation = c.lat != null && c.lon != null + const isMenuOpen = menuOpen === c.id + + return ( +
handleClick(c)} + > + + {hasLocation ? : c.call_sign ? : } + +
+
{c.label}
+
+ {c.name || c.address || c.phone || ''} +
+
+ {c.phone && ( + + + + )} + {c.show_proximity && hasLocation && ( + + prox + + )} + + {/* Three-dot menu button */} + + + {/* Dropdown menu */} + {isMenuOpen && ( +
e.stopPropagation()} + > + {hasLocation && ( + <> + + + + )} + + +
+ )} +
+ ) + })} +
+ )} +
+ ) +}