diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index bb078413..77a3b31d 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -259,9 +259,7 @@ export default function ContactsPage() { setView("edit"); }; - const handleDelete = async () => { - if (!selectedContact) return; - + const deleteContactById = useCallback(async (contactId: string) => { const confirmed = await confirmDialog({ title: t("delete_confirm_title"), message: t("delete_confirm"), @@ -272,18 +270,42 @@ export default function ContactsPage() { try { if (supportsSync && client) { - await deleteContact(client, selectedContact.id); + await deleteContact(client, contactId); } else { - deleteLocalContact(selectedContact.id); + deleteLocalContact(contactId); } toast.success(t("toast.deleted")); - setView("list"); + if (selectedContactId === contactId) setView("list"); } catch (error) { console.error('Failed to delete contact:', error); toast.error(t("toast.error_delete")); } + }, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]); + + const handleDelete = async () => { + if (!selectedContact) return; + await deleteContactById(selectedContact.id); }; + const handleEditContact = useCallback((id: string) => { + setSelectedContact(id); + setView("edit"); + }, [setSelectedContact]); + + const handleDeleteContact = useCallback((contact: ContactCard) => { + void deleteContactById(contact.id); + }, [deleteContactById]); + + const handleAddContactToGroup = useCallback((id: string) => { + clearSelection(); + toggleContactSelection(id); + if (groups.length === 0) { + setView("group-create"); + return; + } + setView("bulk-add-to-group"); + }, [clearSelection, toggleContactSelection, groups.length]); + const handleSaveNew = useCallback(async (data: Partial) => { if (supportsSync && client) { await createContact(client, data); @@ -690,6 +712,9 @@ export default function ContactsPage() { onBulkDelete={handleBulkDelete} onBulkAddToGroup={handleBulkAddToGroup} onBulkExport={handleBulkExport} + onEditContact={handleEditContact} + onDeleteContact={handleDeleteContact} + onAddContactToGroup={handleAddContactToGroup} /> diff --git a/components/contacts/__tests__/contact-list.test.tsx b/components/contacts/__tests__/contact-list.test.tsx index 269c5847..cb066eec 100644 --- a/components/contacts/__tests__/contact-list.test.tsx +++ b/components/contacts/__tests__/contact-list.test.tsx @@ -45,6 +45,9 @@ const defaultProps = { onBulkDelete: vi.fn(), onBulkAddToGroup: vi.fn(), onBulkExport: vi.fn(), + onEditContact: vi.fn(), + onDeleteContact: vi.fn(), + onAddContactToGroup: vi.fn(), }; describe('ContactList', () => { diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx new file mode 100644 index 00000000..9e3c8012 --- /dev/null +++ b/components/contacts/contact-context-menu.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { + ContextMenu, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuHeader, +} from "@/components/ui/context-menu"; +import { + Eye, + Pencil, + Mail, + ClipboardCopy, + Download, + Users, + Trash2, +} from "lucide-react"; +import type { ContactCard } from "@/lib/jmap/types"; +import { getContactPrimaryEmail } from "@/stores/contact-store"; +import { exportContact } from "./contact-export"; +import { toast } from "@/stores/toast-store"; + +interface Position { + x: number; + y: number; +} + +interface ContactContextMenuProps { + contact: ContactCard; + position: Position; + isOpen: boolean; + onClose: () => void; + menuRef: React.RefObject; + isMultiSelect?: boolean; + selectedCount?: number; + onOpen: () => void; + onEdit: () => void; + onDelete: () => void; + onAddToGroup: () => void; + onBatchExport?: () => void; + onBatchAddToGroup?: () => void; + onBatchDelete?: () => void; +} + +export function ContactContextMenu({ + contact, + position, + isOpen, + onClose, + menuRef, + isMultiSelect = false, + selectedCount = 1, + onOpen, + onEdit, + onDelete, + onAddToGroup, + onBatchExport, + onBatchAddToGroup, + onBatchDelete, +}: ContactContextMenuProps) { + const t = useTranslations("contacts"); + const email = getContactPrimaryEmail(contact); + const showBatchActions = isMultiSelect && selectedCount > 1; + + const handle = (fn: () => void) => () => { + fn(); + onClose(); + }; + + const handleSendEmail = () => { + if (!email) return; + window.location.href = `mailto:${email}`; + }; + + const handleCopyEmail = async () => { + if (!email) return; + try { + await navigator.clipboard.writeText(email); + toast.success(t("detail.copied")); + } catch { + toast.error(t("detail.copy_failed")); + } + }; + + const handleExport = () => { + exportContact(contact); + toast.success(t("export.success", { count: 1 })); + }; + + if (showBatchActions) { + return ( + + + {t("bulk.selected", { count: selectedCount })} + + onBatchAddToGroup?.())} + disabled={!onBatchAddToGroup} + /> + onBatchExport?.())} + disabled={!onBatchExport} + /> + + onBatchDelete?.())} + disabled={!onBatchDelete} + destructive + /> + + ); + } + + return ( + + + + {email && ( + <> + + + + + )} + + + + + + + ); +} diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index c54a15e0..04e9c8e4 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -17,9 +17,10 @@ interface ContactListItemProps { selectedContactIds: Set; onClick: (e: React.MouseEvent) => void; onCheckboxClick: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void; } -export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) { +export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) { const name = getContactDisplayName(contact); const email = getContactPrimaryEmail(contact); const org = contact.organizations @@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, draggable onDragStart={handleDragStart} onClick={onClick} + onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined} className={cn( "w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border", isSelected diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index ed0b61e5..21f3a296 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -6,6 +6,8 @@ import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Sq import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ContactListItem } from "./contact-list-item"; +import { ContactContextMenu } from "./contact-context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; @@ -28,6 +30,9 @@ interface ContactListProps { onBulkDelete: () => void; onBulkAddToGroup: () => void; onBulkExport: () => void; + onEditContact: (id: string) => void; + onDeleteContact: (contact: ContactCard) => void; + onAddContactToGroup: (id: string) => void; } export function ContactList({ @@ -47,9 +52,13 @@ export function ContactList({ onBulkDelete, onBulkAddToGroup, onBulkExport, + onEditContact, + onDeleteContact, + onAddContactToGroup, }: ContactListProps) { const t = useTranslations("contacts"); const density = useSettingsStore((state) => state.density); + const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const filtered = useMemo(() => { if (!searchQuery) return contacts; @@ -210,11 +219,31 @@ export function ContactList({ e.stopPropagation(); onToggleSelection(contact.id); }} + onContextMenu={(e, c) => openContextMenu(e, c)} /> ))} )} + + {contextMenu.data && ( + onSelectContact(contextMenu.data!.id)} + onEdit={() => onEditContact(contextMenu.data!.id)} + onDelete={() => onDeleteContact(contextMenu.data!)} + onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)} + onBatchExport={onBulkExport} + onBatchAddToGroup={onBulkAddToGroup} + onBatchDelete={onBulkDelete} + /> + )} ); } diff --git a/locales/en/common.json b/locales/en/common.json index 88951ce0..1a881599 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1979,6 +1979,14 @@ "error_create": "Failed to create contact", "error_update": "Failed to update contact", "error_delete": "Failed to delete contact" + }, + "context_menu": { + "open": "Open", + "edit": "Edit", + "send_email": "Send email", + "add_to_group": "Add to group", + "export_vcard": "Export as vCard", + "delete": "Delete" } }, "calendar": {