"use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ArrowLeft, Users } 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 { exportContacts } from "@/components/contacts/contact-export"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-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 { ResizeHandle } from "@/components/layout/resize-handle"; import { useIsMobile } from "@/hooks/use-media-query"; import type { ContactCard, AddressBook } from "@/lib/jmap/types"; 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 { 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, importContacts, } = useContactStore(); const [view, setView] = useState("list"); const [activeCategory, setActiveCategory] = useState("all"); const [showImportDialog, setShowImportDialog] = useState(false); const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const isMobile = useIsMobile(); // 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) : 320; } catch { return 320; } }); const [isListResizing, setIsListResizing] = useState(false); const listDragStartWidth = useRef(320); // Check auth on mount useEffect(() => { 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]); useEffect(() => { if (client && supportsSync && !hasFetched.current) { hasFetched.current = true; fetchContacts(client); } }, [client, supportsSync, fetchContacts]); 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]); // Label for the current category const categoryLabel = useMemo(() => { if (activeCategory === "all") return t("tabs.all"); if (activeCategory === "uncategorized") return t("no_category"); if ("addressBookId" in activeCategory) { const book = addressBooks.find(b => b.id === activeCategory.addressBookId); return book?.name || t("tabs.all"); } if ("keyword" in activeCategory) { return activeCategory.keyword; } const group = contacts.find(c => c.id === activeCategory.groupId); return group ? getContactDisplayName(group) : t("tabs.all"); }, [activeCategory, contacts, addressBooks, t]); const handleSelectCategory = useCallback((category: ContactCategory) => { setActiveCategory(category); clearSelection(); if (typeof category === "object" && "groupId" in category) { setSelectedGroupId(category.groupId); setView("group-detail"); } else { setSelectedGroupId(null); } }, [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]); 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 handleDelete = async () => { if (!selectedContact) return; 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, selectedContact.id); } else { deleteLocalContact(selectedContact.id); } toast.success(t("toast.deleted")); setView("list"); } catch (error) { console.error('Failed to delete contact:', error); toast.error(t("toast.error_delete")); } }; 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")); } setView("list"); }, [supportsSync, client, createContact, addLocalContact, t]); 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 = () => { 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"); }, []); 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 ( { 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 ( ); } }; const showListPanel = !isMobile || view === "list"; const showRightPanel = !isMobile || view !== "list"; const mobileBackToList = () => { setView("list"); clearSelection(); }; return (
{/* Navigation Rail - desktop only */} {!isMobile && (
)}
{inlineApp && ( )}
{showListPanel && ( <> {/* Panel 1: Categories sidebar */} {!isMobile && ( <>
setShowImportDialog(true)} onEditGroup={handleEditGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar} onDropContacts={handleDropContacts} onDropContactsToCategory={handleDropContactsToCategory} />
{ 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 */}
{!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(320); localStorage.setItem("contacts-list-width", "320"); }} /> )} )} {/* Panel 3: Detail / Form */} {showRightPanel && (
{isMobile && (
)}
{renderRightPanel()}
)}
{isMobile && ( )}
{showImportDialog && (
setShowImportDialog(false)} />
)}
); }