feat: add right-click context menu to contacts

This commit is contained in:
Linus Rath
2026-04-21 19:33:00 +02:00
parent c2e4518cfa
commit 92c7f74420
6 changed files with 234 additions and 7 deletions
+31 -6
View File
@@ -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<ContactCard>) => {
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}
/>
</div>
@@ -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', () => {
@@ -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<HTMLDivElement | null>;
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 (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>
{t("bulk.selected", { count: selectedCount })}
</ContextMenuHeader>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
<ContextMenuItem
icon={Download}
label={t("bulk.export")}
onClick={handle(() => onBatchExport?.())}
disabled={!onBatchExport}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("bulk.delete")}
onClick={handle(() => onBatchDelete?.())}
disabled={!onBatchDelete}
destructive
/>
</ContextMenu>
);
}
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{email && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(handleSendEmail)}
/>
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_email")}
onClick={handle(handleCopyEmail)}
/>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
onClick={handle(handleExport)}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
onClick={handle(onDelete)}
destructive
/>
</ContextMenu>
);
}
+3 -1
View File
@@ -17,9 +17,10 @@ interface ContactListItemProps {
selectedContactIds: Set<string>;
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
+29
View File
@@ -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<ContactCard>();
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)}
/>
))}
</div>
)}
</div>
{contextMenu.data && (
<ContactContextMenu
contact={contextMenu.data}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
menuRef={menuRef}
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
selectedCount={selectedContactIds.size}
onOpen={() => onSelectContact(contextMenu.data!.id)}
onEdit={() => onEditContact(contextMenu.data!.id)}
onDelete={() => onDeleteContact(contextMenu.data!)}
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
/>
)}
</div>
);
}
+8
View File
@@ -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": {