mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
feat: add three-dot actions menu to contact cards
This commit is contained in:
parent
99bd2218a4
commit
30bfd72642
1 changed files with 279 additions and 138 deletions
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue