feat: add three-dot actions menu to contact cards

This commit is contained in:
Matt 2026-04-28 23:34:46 +00:00
commit 30bfd72642

View file

@ -1,138 +1,279 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import { Plus, MapPin, User, Phone, Radio, LogIn } from 'lucide-react' import { Plus, MapPin, User, Phone, Radio, LogIn, MoreVertical, Navigation, Eye, Pencil, Trash2 } from 'lucide-react'
import { useStore } from '../store' import toast from 'react-hot-toast'
import { fetchContacts } from '../api' import { useStore } from '../store'
import { fetchContacts, deleteContact } from '../api'
export default function ContactList() {
const contacts = useStore((s) => s.contacts) export default function ContactList() {
const contactsLoaded = useStore((s) => s.contactsLoaded) const contacts = useStore((s) => s.contacts)
const setContacts = useStore((s) => s.setContacts) const contactsLoaded = useStore((s) => s.contactsLoaded)
const setEditingContact = useStore((s) => s.setEditingContact) const setContacts = useStore((s) => s.setContacts)
const setSelectedPlace = useStore((s) => s.setSelectedPlace) const setEditingContact = useStore((s) => s.setEditingContact)
const auth = useStore((s) => s.auth) const setSelectedPlace = useStore((s) => s.setSelectedPlace)
const setClickMarker = useStore((s) => s.setClickMarker)
const [filter, setFilter] = useState('') const startDirections = useStore((s) => s.startDirections)
const setActiveTab = useStore((s) => s.setActiveTab)
const loadContacts = useCallback(async () => { const auth = useStore((s) => s.auth)
// Skip fetch entirely if not authenticated
if (!auth.authenticated) return const [filter, setFilter] = useState('')
const data = await fetchContacts() const [menuOpen, setMenuOpen] = useState(null) // contact id or null
if (Array.isArray(data)) { const menuRef = useRef(null)
setContacts(data)
} const loadContacts = useCallback(async () => {
}, [setContacts, auth.authenticated]) if (!auth.authenticated) return
const data = await fetchContacts()
useEffect(() => { if (Array.isArray(data)) {
if (auth.loaded && auth.authenticated && !contactsLoaded) { setContacts(data)
loadContacts() }
} }, [setContacts, auth.authenticated])
}, [auth.loaded, auth.authenticated, contactsLoaded, loadContacts])
useEffect(() => {
// Show login prompt if not authenticated if (auth.loaded && auth.authenticated && !contactsLoaded) {
if (auth.loaded && !auth.authenticated) { loadContacts()
return ( }
<div className="mt-6 text-center"> }, [auth.loaded, auth.authenticated, contactsLoaded, loadContacts])
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
Sign in to save and sync your contacts // Close menu on outside click or Escape
</p> useEffect(() => {
<button if (!menuOpen) return
onClick={() => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' }} const handleClick = (e) => {
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium" if (menuRef.current && !menuRef.current.contains(e.target)) {
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }} setMenuOpen(null)
> }
<LogIn size={12} /> }
Log in const handleKey = (e) => {
</button> if (e.key === 'Escape') setMenuOpen(null)
</div> }
) document.addEventListener('mousedown', handleClick)
} document.addEventListener('keydown', handleKey)
return () => {
const q = filter.toLowerCase() document.removeEventListener('mousedown', handleClick)
const filtered = q document.removeEventListener('keydown', handleKey)
? contacts.filter((c) => }
(c.label || '').toLowerCase().includes(q) || }, [menuOpen])
(c.name || '').toLowerCase().includes(q) ||
(c.call_sign || '').toLowerCase().includes(q) || // Show login prompt if not authenticated
(c.phone || '').includes(q) if (auth.loaded && !auth.authenticated) {
) return (
: contacts <div className="mt-6 text-center">
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
const handleClick = (c) => { Sign in to save and sync your contacts
if (c.lat != null && c.lon != null) { </p>
setSelectedPlace({ <button
lat: c.lat, onClick={() => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' }}
lon: c.lon, className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
name: c.label, style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
address: c.address || null, >
type: 'contact', <LogIn size={12} />
source: 'contacts', Log in
matchCode: null, </button>
raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c }, </div>
}) )
} else { }
setEditingContact(c)
} const q = filter.toLowerCase()
} const filtered = q
? contacts.filter((c) =>
return ( (c.label || '').toLowerCase().includes(q) ||
<div className="mt-2"> (c.name || '').toLowerCase().includes(q) ||
{/* Search + add */} (c.call_sign || '').toLowerCase().includes(q) ||
<div className="flex gap-2 mb-2"> (c.phone || '').includes(q)
<input )
className="navi-input flex-1" : contacts
placeholder="Filter contacts..."
value={filter} const handleClick = (c) => {
onChange={(e) => setFilter(e.target.value)} if (c.lat != null && c.lon != null) {
/> setSelectedPlace({
<button lat: c.lat,
onClick={() => setEditingContact({})} lon: c.lon,
className="p-2 rounded-lg shrink-0" name: c.label,
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }} address: c.address || null,
title="New contact" type: 'contact',
> source: 'contacts',
<Plus size={14} /> matchCode: null,
</button> raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
</div> })
} else {
{/* List */} setEditingContact(c)
{filtered.length === 0 ? ( }
<div className="mt-4 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}> }
{contacts.length === 0 ? 'No contacts yet' : 'No matches'}
</div> const handleMenuClick = (e, contactId) => {
) : ( e.stopPropagation()
<div className="flex flex-col"> setMenuOpen(menuOpen === contactId ? null : contactId)
{filtered.map((c) => ( }
<div
key={c.id} const handleDirections = (c) => {
className="contact-item" setMenuOpen(null)
onClick={() => handleClick(c)} startDirections({ lat: c.lat, lon: c.lon, name: c.label })
> }
<span className="shrink-0" style={{ color: 'var(--text-tertiary)' }}>
{c.lat != null ? <MapPin size={14} /> : c.call_sign ? <Radio size={14} /> : <User size={14} />} const handleViewOnMap = (c) => {
</span> setMenuOpen(null)
<div className="flex-1 min-w-0"> // Set click marker at location
<div className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{c.label}</div> setClickMarker({ lat: c.lat, lon: c.lon })
<div className="text-[11px] truncate" style={{ color: 'var(--text-tertiary)' }}> // Set selected place to trigger map fly and place card
{c.name || c.address || c.phone || ''} setSelectedPlace({
</div> lat: c.lat,
</div> lon: c.lon,
{c.phone && ( name: c.label,
<span className="text-[10px] shrink-0" style={{ color: 'var(--text-tertiary)' }}> address: c.address || null,
<Phone size={10} /> type: 'contact',
</span> source: 'contacts',
)} matchCode: null,
{c.show_proximity && c.lat != null && ( raw: { osm_type: c.osm_type, osm_id: c.osm_id, contact: c },
<span })
className="text-[9px] px-1 py-0.5 rounded shrink-0" // Switch to routes tab to close contacts panel
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }} setActiveTab('routes')
> }
prox
</span> const handleEdit = (c) => {
)} setMenuOpen(null)
</div> setEditingContact(c)
))} }
</div>
)} const handleDelete = async (c) => {
</div> 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 (
<div className="mt-2">
{/* Search + add */}
<div className="flex gap-2 mb-2">
<input
className="navi-input flex-1"
placeholder="Filter contacts..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<button
onClick={() => setEditingContact({})}
className="p-2 rounded-lg shrink-0"
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
title="New contact"
>
<Plus size={14} />
</button>
</div>
{/* List */}
{filtered.length === 0 ? (
<div className="mt-4 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
{contacts.length === 0 ? 'No contacts yet' : 'No matches'}
</div>
) : (
<div className="flex flex-col">
{filtered.map((c) => {
const hasLocation = c.lat != null && c.lon != null
const isMenuOpen = menuOpen === c.id
return (
<div
key={c.id}
className="contact-item relative"
onClick={() => handleClick(c)}
>
<span className="shrink-0" style={{ color: 'var(--text-tertiary)' }}>
{hasLocation ? <MapPin size={14} /> : c.call_sign ? <Radio size={14} /> : <User size={14} />}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{c.label}</div>
<div className="text-[11px] truncate" style={{ color: 'var(--text-tertiary)' }}>
{c.name || c.address || c.phone || ''}
</div>
</div>
{c.phone && (
<span className="text-[10px] shrink-0" style={{ color: 'var(--text-tertiary)' }}>
<Phone size={10} />
</span>
)}
{c.show_proximity && hasLocation && (
<span
className="text-[9px] px-1 py-0.5 rounded shrink-0"
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
>
prox
</span>
)}
{/* Three-dot menu button */}
<button
onClick={(e) => handleMenuClick(e, c.id)}
className="shrink-0 p-1 rounded hover:bg-[var(--bg-overlay)]"
style={{ color: 'var(--text-tertiary)' }}
title="Actions"
>
<MoreVertical size={14} />
</button>
{/* Dropdown menu */}
{isMenuOpen && (
<div
ref={menuRef}
className="absolute right-0 top-full mt-1 z-50 rounded-lg overflow-hidden"
style={{
background: 'var(--bg-raised)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
minWidth: '140px',
}}
onClick={(e) => e.stopPropagation()}
>
{hasLocation && (
<>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-[var(--bg-overlay)]"
style={{ color: 'var(--text-primary)' }}
onClick={() => handleDirections(c)}
>
<Navigation size={12} />
Directions to
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-[var(--bg-overlay)]"
style={{ color: 'var(--text-primary)' }}
onClick={() => handleViewOnMap(c)}
>
<Eye size={12} />
View on map
</button>
</>
)}
<button
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-[var(--bg-overlay)]"
style={{ color: 'var(--text-primary)' }}
onClick={() => handleEdit(c)}
>
<Pencil size={12} />
Edit
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-[var(--bg-overlay)]"
style={{ color: 'var(--status-danger)' }}
onClick={() => handleDelete(c)}
>
<Trash2 size={12} />
Delete
</button>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}