feat: add right-click context menu to contacts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user