"use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; import { useRouter } from "@/i18n/navigation"; import { ArrowLeft, Users, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { ContactList } from "@/components/contacts/contact-list"; import { ContactDetail } from "@/components/contacts/contact-detail"; import { ContactForm } from "@/components/contacts/contact-form"; import { ContactGroupForm } from "@/components/contacts/contact-group-form"; import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { RenameDialog } from "@/components/files/rename-dialog"; import { exportContacts } from "@/components/contacts/contact-export"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { savePendingMailto } from "@/lib/protocol-handlers/session"; import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { usePolicyStore } from "@/stores/policy-store"; import { toast } from "@/stores/toast-store"; import { cn, generateUUID } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; type View = | "list" | "detail" | "create" | "edit" | "group-detail" | "group-create" | "group-edit" | "bulk-add-to-group"; export default function ContactsPage() { const t = useTranslations("contacts"); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { contacts, addressBooks, selectedContactId, searchQuery, supportsSync, selectedContactIds, setSelectedContact, setSearchQuery, fetchContacts, createContact, updateContact, deleteContact, addLocalContact, updateLocalContact, deleteLocalContact, getGroupMembers, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, deleteGroup, toggleContactSelection, selectRangeContacts, selectAllContacts, clearSelection, bulkDeleteContacts, bulkAddToGroup, moveContactToAddressBook, createAddressBook, renameAddressBook, removeAddressBook, shareAddressBook, renameKeyword, importContacts, } = useContactStore(); const [view, setView] = useState("list"); const [activeCategory, setActiveCategory] = useState("all"); const [showImportDialog, setShowImportDialog] = useState(false); const [renamingAddressBook, setRenamingAddressBook] = useState(null); const [creatingAddressBook, setCreatingAddressBook] = useState(false); const [sharingAddressBookId, setSharingAddressBookId] = useState(null); const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState(undefined); const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined); const [returnToEmail, setReturnToEmail] = useState(false); const [renamingKeyword, setRenamingKeyword] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const isMobile = useIsMobile(); const isDesktop = useIsDesktop(); const isEmbedded = useIsEmbedded(); const router = useRouter(); const searchParams = useSearchParams(); // One-shot intent flag: only consume the URL params on the first render that // has them. After applying, we strip the query so a later refresh or // re-mount doesn't re-trigger the navigation. const intentAppliedRef = useRef(false); // Narrow pane (Pro split or small window): the categories sidebar collapses // into a burger-toggled overlay. const isNarrow = !isDesktop; const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false); useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]); // Panel resize state - sidebar (categories) const [sidebarWidth, setSidebarWidth] = useState(() => { try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; } }); const [isSidebarResizing, setIsSidebarResizing] = useState(false); const sidebarDragStartWidth = useRef(256); // Panel resize state - contact list const [listWidth, setListWidth] = useState(() => { try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; } }); const [isListResizing, setIsListResizing] = useState(false); const listDragStartWidth = useRef(384); // Check auth on mount – skip when already authenticated so that navigating // between routes doesn't retrigger checkAuth's transient `{ client: null, // isLoading: true }` reset, which was flashing the spinner on every nav. useEffect(() => { const state = useAuthStore.getState(); if (state.isAuthenticated && state.client) { setInitialCheckDone(true); return; } checkAuth().finally(() => { setInitialCheckDone(true); }); }, [checkAuth]); useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } redirectToLogin(); } }, [initialCheckDone, isAuthenticated, authLoading]); // Pro shell only: aggregate contacts and address books from every // connected account so the sidebar lists them all. The hook is a no-op // outside the embedded shell. const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts(); useEffect(() => { if (isEmbedded) return; if (client && supportsSync && !hasFetched.current) { hasFetched.current = true; fetchContacts(client); } }, [client, supportsSync, fetchContacts, isEmbedded]); // Consume one-shot URL params (set by the mobile recipient popover when no // sidebar is available) and strip them so a refresh doesn't replay the // intent. `from=email` flips the mobile back button to `router.back()`. useEffect(() => { if (intentAppliedRef.current) return; const contactId = searchParams.get('contactId'); const addEmail = searchParams.get('addEmail'); const addName = searchParams.get('addName'); const from = searchParams.get('from'); const viewParam = searchParams.get('view'); if (!contactId && !addEmail && !from) return; intentAppliedRef.current = true; if (from === 'email') setReturnToEmail(true); if (contactId) { setSelectedContact(contactId); setView(viewParam === 'edit' ? 'edit' : 'detail'); } else if (addEmail) { setCreatePrefill({ email: addEmail, name: addName ?? undefined }); setSelectedContact(null); setView('create'); } router.replace('/contacts'); }, [searchParams, router, setSelectedContact]); // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // and refresh contacts via JMAP instead of reloading the page. useRefreshGesture({ enabled: isAuthenticated && !!client && supportsSync, onRefresh: async () => { if (!client) return; if (multiAccountEnabled && accountClients.length > 0) { const activeId = useAuthStore.getState().activeAccountId; if (activeId) { const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState(); await Promise.all([ fetchAllAccountsAddressBooks(accountClients, activeId), fetchAllAccountsContacts(accountClients, activeId), ]); return; } } await fetchContacts(client); }, }); const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]); const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]); const selectedContact = contacts.find((c) => c.id === selectedContactId) || null; const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]); // Collect all unique keywords across contacts const allKeywords = useMemo(() => { const kws = new Set(); for (const contact of individuals) { if (!contact.keywords) continue; for (const [kw, active] of Object.entries(contact.keywords)) { if (active) kws.add(kw); } } return Array.from(kws).sort((a, b) => a.localeCompare(b)); }, [individuals]); // Contacts to display based on active category const displayedContacts = useMemo(() => { if (activeCategory === "all") return individuals; if (activeCategory === "uncategorized") { return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0); } if ("addressBookId" in activeCategory) { const bookId = activeCategory.addressBookId; return individuals.filter(c => { if (!c.addressBookIds) return false; return c.addressBookIds[bookId] === true; }); } if ("keyword" in activeCategory) { return individuals.filter(c => c.keywords?.[activeCategory.keyword]); } // Show members of the selected group return getGroupMembers(activeCategory.groupId); }, [activeCategory, individuals, getGroupMembers]); const handleSelectCategory = useCallback((category: ContactCategory) => { setActiveCategory(category); clearSelection(); if (typeof category === "object" && "groupId" in category) { setSelectedGroupId(category.groupId); setView("group-detail"); } else { setSelectedGroupId(null); } setNarrowSidebarOpen(false); }, [clearSelection]); const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => { if (!client) return; try { await moveContactToAddressBook(client, contactIds, addressBook); const msg = contactIds.length === 1 ? t("address_books.moved", { name: addressBook.name }) : t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name }); toast.success(msg); } catch (error) { console.error('Failed to move contacts:', error); toast.error(t("address_books.move_failed")); } }, [client, moveContactToAddressBook, t]); const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => { if (!client && supportsSync) return; try { for (const contactId of contactIds) { const contact = contacts.find(c => c.id === contactId); if (!contact) continue; const existingKeywords = contact.keywords || {}; if (existingKeywords[keyword]) continue; // already has this keyword const updatedKeywords = { ...existingKeywords, [keyword]: true }; if (supportsSync && client) { await updateContact(client, contactId, { keywords: updatedKeywords }); } else { updateLocalContact(contactId, { keywords: updatedKeywords }); } } const msg = contactIds.length === 1 ? t("category_added", { name: keyword }) : t("category_added_plural", { count: contactIds.length, name: keyword }); toast.success(msg); } catch (error) { console.error('Failed to add contacts to category:', error); toast.error(t("toast.error_update")); } }, [client, supportsSync, contacts, updateContact, updateLocalContact, t]); // Refresh address books (and contacts) after a structural change, staying // multi-account aware so a freshly created book lands in the sidebar. const refreshAddressBooks = useCallback(async () => { if (!client) return; if (multiAccountEnabled && accountClients.length > 0) { const activeId = useAuthStore.getState().activeAccountId; if (activeId) { const { fetchAllAccountsAddressBooks } = useContactStore.getState(); await fetchAllAccountsAddressBooks(accountClients, activeId); return; } } await useContactStore.getState().fetchAddressBooks(client); }, [client, multiAccountEnabled, accountClients]); const handleCreateAddressBook = useCallback(async (name: string) => { if (!client) return; try { await createAddressBook(client, name); await refreshAddressBooks(); toast.success(t("address_books.created")); setCreatingAddressBook(false); } catch (error) { console.error('Failed to create address book:', error); toast.error(t("address_books.create_failed")); } }, [client, createAddressBook, refreshAddressBooks, t]); const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => { return importContacts( supportsSync && client ? client : null, importedContacts ); }, [supportsSync, client, importContacts]); const handleSelectContact = (id: string) => { setSelectedContact(id); clearSelection(); setView("detail"); }; const handleCreateNew = () => { setSelectedContact(null); setView("create"); }; const handleEdit = () => { setView("edit"); }; const deleteContactById = useCallback(async (contactId: string) => { const confirmed = await confirmDialog({ title: t("delete_confirm_title"), message: t("delete_confirm"), confirmText: t("form.delete"), variant: "destructive", }); if (!confirmed) return; try { if (supportsSync && client) { await deleteContact(client, contactId); } else { deleteLocalContact(contactId); } toast.success(t("toast.deleted")); 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 handleDuplicateContact = useCallback(async (source: ContactCard) => { const { id: _id, created: _created, updated: _updated, ...rest } = source; void _id; void _created; void _updated; const data: Partial = JSON.parse(JSON.stringify(rest)); if (supportsSync && client) { await createContact(client, data); toast.success(t("toast.created")); } else { const localContact: ContactCard = { id: `local-${generateUUID()}`, addressBookIds: data.addressBookIds || {}, ...data, }; addLocalContact(localContact); toast.success(t("toast.created")); } }, [supportsSync, client, createContact, addLocalContact, t]); const handleSaveNew = useCallback(async (data: Partial) => { if (supportsSync && client) { await createContact(client, data); toast.success(t("toast.created")); } else { const localContact: ContactCard = { id: `local-${generateUUID()}`, addressBookIds: {}, ...data, }; addLocalContact(localContact); toast.success(t("toast.created")); } setDefaultBookIdForCreate(undefined); setCreatePrefill(undefined); if (returnToEmail) { setReturnToEmail(false); router.back(); return; } setView("list"); }, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]); const handleSaveEdit = useCallback(async (data: Partial) => { if (!selectedContact) return; if (supportsSync && client) { await updateContact(client, selectedContact.id, data); toast.success(t("toast.updated")); } else { updateLocalContact(selectedContact.id, data); toast.success(t("toast.updated")); } setView("detail"); }, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]); const handleCancel = () => { setDefaultBookIdForCreate(undefined); // Came from email → cancel returns to the email instead of the contact list. if (returnToEmail && view === "create") { setCreatePrefill(undefined); setReturnToEmail(false); router.back(); return; } if (view === "create") setCreatePrefill(undefined); if (view === "group-create" || view === "group-edit") { setView(selectedGroup ? "group-detail" : "list"); } else if (view === "bulk-add-to-group") { setView("list"); } else { setView(selectedContact ? "detail" : "list"); } }; const _handleSelectGroup = (id: string) => { setSelectedGroupId(id); setActiveCategory({ groupId: id }); setView("group-detail"); }; const handleCreateGroup = () => { setSelectedGroupId(null); setView("group-create"); }; const handleEditGroup = () => { setView("group-edit"); }; const handleEditGroupFromSidebar = useCallback((groupId: string) => { setSelectedGroupId(groupId); setActiveCategory({ groupId }); setView("group-edit"); }, []); // Open the in-app composer in the current session rather than routing through // a mailto: URL. `window.location='mailto:'` hands off to the OS handler // (which may open a different mail app), and the mailto protocol round-trip // reloads the app - dropping the in-memory per-account JMAP clients of a // multi-account session, which reads as a logout. Stashing the recipients and // doing a client-side router.push keeps the session and the active account // intact; the main route consumes the pending compose and opens the composer // (see consumePendingMailto in page.tsx). const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => { savePendingMailto({ to: field === "to" ? recipients : [], cc: field === "cc" ? recipients : [], bcc: field === "bcc" ? recipients : [], subject: "", body: "", }); router.push("/"); }, [router]); const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => { // Hand the composer a single group chip (RFC 5322 group syntax survives // the string hand-off) instead of one entry per member - the chip expands // into the members when the message is sent. Dedupe by email, // case-insensitively; members without an email are skipped. const seen = new Set(); const members: Array<{ name?: string; email: string }> = []; for (const member of getGroupMembers(groupId)) { const email = getContactPrimaryEmail(member).trim(); const key = email.toLowerCase(); if (!email || seen.has(key)) continue; seen.add(key); const name = getContactDisplayName(member); members.push({ name: name && name !== email ? name : undefined, email }); } if (members.length === 0) { toast.error(t("groups.no_member_emails")); return; } const group = useContactStore.getState().contacts.find((c) => c.id === groupId); const chip: Recipient = { name: (group && getContactDisplayName(group)) || "Group", email: "", group: { members }, }; openComposeInApp([formatRecipientEntry(chip)], field); }, [getGroupMembers, t, openComposeInApp]); const handleComposeContact = useCallback((contact: ContactCard) => { const email = getContactPrimaryEmail(contact).trim(); if (!email) return; openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to"); }, [openComposeInApp]); const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => { const confirmed = await confirmDialog({ title: t("groups.delete_confirm_title"), message: t("groups.delete_confirm"), confirmText: t("form.delete"), variant: "destructive", }); if (!confirmed) return; try { await deleteGroup(supportsSync && client ? client : null, groupId); toast.success(t("toast.deleted")); if (selectedGroupId === groupId) { setSelectedGroupId(null); setActiveCategory("all"); setView("list"); } } catch (error) { console.error('Failed to delete group:', error); toast.error(t("toast.error_delete")); } }, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]); const handleDeleteGroup = async () => { if (!selectedGroup) return; const confirmed = await confirmDialog({ title: t("groups.delete_confirm_title"), message: t("groups.delete_confirm"), confirmText: t("form.delete"), variant: "destructive", }); if (!confirmed) return; try { await deleteGroup(supportsSync && client ? client : null, selectedGroup.id); toast.success(t("toast.deleted")); setSelectedGroupId(null); setView("list"); } catch (error) { console.error('Failed to delete group:', error); toast.error(t("toast.error_delete")); } }; const handleSaveGroup = useCallback(async (name: string, memberIds: string[]) => { const jmapClient = supportsSync && client ? client : null; if (view === "group-edit" && selectedGroup) { await updateGroup(jmapClient, selectedGroup.id, name); // Use resolved member contact IDs for diff, not raw urn:uuid: keys const currentIds = selectedGroupMembers.map(m => m.id); const toAdd = memberIds.filter(id => !currentIds.includes(id)); const toRemove = currentIds.filter(id => !memberIds.includes(id)); if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd); if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove); toast.success(t("toast.updated")); setView("group-detail"); } else { await createGroup(jmapClient, name, memberIds); toast.success(t("toast.created")); setView("list"); } }, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]); const handleRemoveGroupMember = async (memberId: string) => { if (!selectedGroup) return; try { await removeMembersFromGroup( supportsSync && client ? client : null, selectedGroup.id, [memberId] ); toast.success(t("toast.updated")); } catch (error) { console.error('Failed to remove group member:', error); toast.error(t("toast.error_update")); } }; const handleBulkDelete = async () => { if (selectedContactIds.size === 0) return; const confirmed = await confirmDialog({ title: t("bulk.delete_confirm_title"), message: t("bulk.delete_confirm", { count: selectedContactIds.size }), confirmText: t("bulk.delete"), variant: "destructive", }); if (!confirmed) return; try { await bulkDeleteContacts( supportsSync && client ? client : null, Array.from(selectedContactIds) ); toast.success(t("bulk.deleted", { count: selectedContactIds.size })); setView("list"); } catch (error) { console.error('Failed to bulk delete contacts:', error); toast.error(t("toast.error_delete")); } }; const handleBulkAddToGroup = () => { if (selectedContactIds.size === 0) return; if (groups.length === 0) { setView("group-create"); return; } setView("bulk-add-to-group"); }; const handleBulkExport = () => { const toExport = contacts.filter(c => selectedContactIds.has(c.id)); if (toExport.length > 0) { exportContacts(toExport); toast.success(t("export.success", { count: toExport.length })); clearSelection(); } }; const handleBulkAddToGroupConfirm = async (groupId: string) => { try { await bulkAddToGroup( supportsSync && client ? client : null, groupId, Array.from(selectedContactIds) ); toast.success(t("bulk.added_to_group")); setView("list"); } catch (error) { console.error('Failed to add contacts to group:', error); toast.error(t("toast.error_update")); } }; if (!isAuthenticated) return null; const renderRightPanel = () => { switch (view) { case "create": return ; case "edit": if (!selectedContact) return null; return ( ); case "group-detail": if (!selectedGroup) return null; return ( handleComposeGroupFromSidebar(selectedGroup.id, field)} isMobile={isMobile} onSelectMember={(id) => { setSelectedContact(id); setView("detail"); }} /> ); case "group-create": return ( ); case "group-edit": if (!selectedGroup) return null; return ( m.id)} onSave={handleSaveGroup} onCancel={handleCancel} /> ); case "bulk-add-to-group": return (

{t("bulk.choose_group")}

{t("bulk.adding_contacts", { count: selectedContactIds.size })}

{groups.map((group) => { const gName = getContactDisplayName(group); const memberCount = group.members ? Object.values(group.members).filter(Boolean).length : 0; return ( ); })}
); default: return ( handleComposeContact(selectedContact) : undefined } onAddToGroup={ selectedContact ? () => handleAddContactToGroup(selectedContact.id) : undefined } onDuplicate={ selectedContact ? () => void handleDuplicateContact(selectedContact) : undefined } isMobile={isMobile} /> ); } }; if (!contactsEnabled) { return (

Contacts feature is disabled by your administrator

Please contact your administrator if you need access.

); } const showListPanel = !isMobile || view === "list"; const showRightPanel = !isMobile || view !== "list"; const mobileBackToList = () => { if (returnToEmail) { setReturnToEmail(false); setCreatePrefill(undefined); router.back(); return; } setView("list"); clearSelection(); }; return (
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */} {!isMobile && !isEmbedded && (
)}
{inlineApp && ( )}
{/* Narrow-pane backdrop for the overlay categories sidebar */} {isNarrow && narrowSidebarOpen && (
setNarrowSidebarOpen(false)} /> )} {showListPanel && ( <> {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */} {(!isMobile || isNarrow) && ( <>
setCreatingAddressBook(true) : undefined} onImport={() => setShowImportDialog(true)} onEditGroup={handleEditGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar} onComposeGroup={handleComposeGroupFromSidebar} onDropContacts={handleDropContacts} onDropContactsToCategory={handleDropContactsToCategory} onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined} onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined} onCreateContactInBook={(book) => { setDefaultBookIdForCreate(book.id); handleCreateNew(); }} onDeleteAddressBook={client ? async (book) => { const ok = await confirmDialog({ title: t("address_books.delete"), message: t("address_books.confirm_delete", { name: book.name }), variant: "destructive", confirmText: t("address_books.delete"), }); if (!ok) return; try { await removeAddressBook(client, book); toast.success(t("address_books.deleted")); } catch { toast.error(t("address_books.delete_failed")); } } : undefined} onRenameKeyword={(kw) => setRenamingKeyword(kw)} multiAccountMode={multiAccountEnabled && accountClients.length > 1} />
{!isNarrow && ( { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} onResizeEnd={() => { setIsSidebarResizing(false); localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); }} onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} /> )} )} {/* Panel 2: Contact list */}
setNarrowSidebarOpen(true) : undefined} />
{!isMobile && ( { listDragStartWidth.current = listWidth; setIsListResizing(true); }} onResize={(delta) => setListWidth(Math.max(220, Math.min(500, listDragStartWidth.current + delta)))} onResizeEnd={() => { setIsListResizing(false); localStorage.setItem("contacts-list-width", String(listWidth)); }} onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }} /> )} )} {/* Panel 3: Detail / Form */} {showRightPanel && (
{isMobile && (
)}
{renderRightPanel()}
)}
{isMobile && !isEmbedded && ( )}
{renamingKeyword !== null && ( setRenamingKeyword(null)} onConfirm={async (newName) => { try { await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName); toast.success(t("category_renamed")); if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) { setActiveCategory({ keyword: newName.trim() }); } setRenamingKeyword(null); } catch (err) { console.error("Failed to rename category:", err); toast.error(t("category_rename_failed")); } }} /> )} {creatingAddressBook && ( setCreatingAddressBook(false)} onConfirm={handleCreateAddressBook} /> )} {renamingAddressBook && ( setRenamingAddressBook(null)} onConfirm={async (newName) => { if (!client) return; try { await renameAddressBook(client, renamingAddressBook, newName); toast.success(t("address_books.renamed")); setRenamingAddressBook(null); } catch (err) { console.error("Failed to rename address book:", err); toast.error(t("address_books.rename_failed")); } }} /> )} {showImportDialog && (
setShowImportDialog(false)} />
)} {sharingAddressBookId && client && (() => { const book = addressBooks.find((b) => b.id === sharingAddressBookId); if (!book) return null; return ( { await shareAddressBook(client, book, principalId, rights as AddressBookRights | null); }} onClose={() => setSharingAddressBookId(null)} /> ); })()}
); }