"use client"; import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useTranslations } from "next-intl"; import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; import { useAccountStore } from "@/stores/account-store"; export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; interface ContactsSidebarProps { groups: ContactCard[]; individuals: ContactCard[]; addressBooks: AddressBook[]; activeCategory: ContactCategory; onSelectCategory: (category: ContactCategory) => void; onCreateGroup: () => void; onCreateContact: () => void; onImport?: () => void; onEditGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; onRenameAddressBook?: (addressBook: AddressBook) => void; onShareAddressBook?: (addressBook: AddressBook) => void; onCreateContactInBook?: (addressBook: AddressBook) => void; onDeleteAddressBook?: (addressBook: AddressBook) => void; onRenameKeyword?: (keyword: string) => void; className?: string; /** * Pro shell: render one collapsible section per connected local account * (active first), each with "My Address Books" / "Shared from X" * subsections. Mirrors the calendar sidebar's Pro layout. */ multiAccountMode?: boolean; } type AddressBookAccountSplit = { owned: AddressBook[]; sharedGroups: { label: string; books: AddressBook[] }[]; }; function splitAccountBooks(list: AddressBook[]): AddressBookAccountSplit { const owned: AddressBook[] = []; const sharedBuckets = new Map(); for (const book of list) { if (book.isShared) { const key = book.accountId || book.accountName || book.id; const bucket = sharedBuckets.get(key); if (bucket) { bucket.books.push(book); } else { sharedBuckets.set(key, { label: book.accountName || key, books: [book] }); } } else { owned.push(book); } } return { owned, sharedGroups: Array.from(sharedBuckets.values()) }; } const COLLAPSED_KEY = "contacts-sidebar-collapsed"; function loadCollapsed(): Record { try { const v = localStorage.getItem(COLLAPSED_KEY); return v ? JSON.parse(v) : {}; } catch { return {}; } } function saveCollapsed(state: Record) { try { localStorage.setItem(COLLAPSED_KEY, JSON.stringify(state)); } catch { /* ignore */ } } export function ContactsSidebar({ groups, individuals, addressBooks, activeCategory, onSelectCategory, onCreateGroup, onCreateContact, onImport, onEditGroup, onDeleteGroup, onDropContacts, onDropContactsToCategory, onRenameAddressBook, onShareAddressBook, onCreateContactInBook, onDeleteAddressBook, onRenameKeyword, className, multiAccountMode, }: ContactsSidebarProps) { const t = useTranslations("contacts"); const router = useRouter(); const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu(); const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu(); const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu(); const [collapsed, setCollapsed] = useState>(loadCollapsed); const [showMenu, setShowMenu] = useState(false); const menuRef = useRef(null); const menuBtnRef = useRef(null); const toggleSection = useCallback((key: string) => { setCollapsed(prev => { const next = { ...prev, [key]: !prev[key] }; saveCollapsed(next); return next; }); }, []); // Close dropdown on outside click useEffect(() => { if (!showMenu) return; const handler = (e: MouseEvent) => { if ( menuRef.current && !menuRef.current.contains(e.target as Node) && menuBtnRef.current && !menuBtnRef.current.contains(e.target as Node) ) { setShowMenu(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [showMenu]); const sortedGroups = useMemo(() => { return [...groups].sort((a, b) => getContactDisplayName(a).localeCompare(getContactDisplayName(b)) ); }, [groups]); const isAllActive = activeCategory === "all"; // Group address books: personal vs shared accounts const personalBooks = useMemo(() => addressBooks.filter(b => !b.isShared), [addressBooks]); const sharedBookGroups = useMemo(() => { const map = new Map(); for (const book of addressBooks) { if (!book.isShared || !book.accountId) continue; const existing = map.get(book.accountId); if (existing) { existing.books.push(book); } else { map.set(book.accountId, { accountId: book.accountId, accountName: book.accountName || book.accountId, books: [book], }); } } return Array.from(map.values()); }, [addressBooks]); // Pro / multi-account grouping: each local account is its own collapsible // section with owned / shared sub-buckets. const localAccounts = useAccountStore((s) => s.accounts); const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); const localAccountGroups = useMemo(() => { if (!multiAccountMode) return []; const byAccount = new Map(); for (const book of addressBooks) { const key = book.localAccountId || '__other__'; const list = byAccount.get(key) ?? []; list.push(book); byAccount.set(key, list); } const ordered: { key: string; label: string; split: AddressBookAccountSplit }[] = []; if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) { const acct = localAccounts.find(a => a.id === activeLocalAccountId); ordered.push({ key: activeLocalAccountId, label: acct?.label || acct?.email || acct?.username || activeLocalAccountId, split: splitAccountBooks(byAccount.get(activeLocalAccountId)!), }); byAccount.delete(activeLocalAccountId); } for (const acct of localAccounts) { if (!byAccount.has(acct.id)) continue; ordered.push({ key: acct.id, label: acct.label || acct.email || acct.username, split: splitAccountBooks(byAccount.get(acct.id)!), }); byAccount.delete(acct.id); } for (const [key, list] of byAccount.entries()) { const fallback = key === '__other__' ? t('address_books.title') : list[0]?.accountName || key; ordered.push({ key, label: fallback, split: splitAccountBooks(list) }); } return ordered; }, [multiAccountMode, addressBooks, localAccounts, activeLocalAccountId, t]); // Count contacts per address book const contactCountByBook = useMemo(() => { const counts: Record = {}; for (const contact of individuals) { if (!contact.addressBookIds) continue; for (const bookId of Object.keys(contact.addressBookIds)) { if (!contact.addressBookIds[bookId]) continue; counts[bookId] = (counts[bookId] || 0) + 1; } } return counts; }, [individuals]); // Auto-collect keywords from all contacts const allKeywords = useMemo(() => { const counts: Record = {}; for (const contact of individuals) { if (!contact.keywords) continue; for (const [kw, active] of Object.entries(contact.keywords)) { if (!active) continue; counts[kw] = (counts[kw] || 0) + 1; } } return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)); }, [individuals]); // Count of contacts without any keywords const uncategorizedCount = useMemo(() => { return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0).length; }, [individuals]); // Resolve actual group member counts against living contacts const memberCountByGroup = useMemo(() => { const counts: Record = {}; for (const group of groups) { if (!group.members) { counts[group.id] = 0; continue; } const memberKeys = Object.keys(group.members).filter(k => group.members![k]); const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k); counts[group.id] = individuals.filter(c => { if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true; if (c.uid) { const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid; return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid); } return false; }).length; } return counts; }, [groups, individuals]); return (
{/* Header */}
{t("title")}
{showMenu && (
{onImport && ( )}
)}
{/* Navigation */}
{/* All contacts */} {/* Address Books: per-account groups in multi-account Pro mode, else the classic "My Address Books" section. */} {multiAccountMode && localAccountGroups.length > 0 ? ( localAccountGroups.map((group) => { const sectionKey = `account-${group.key}`; const expanded = !collapsed[sectionKey]; const { owned, sharedGroups } = group.split; return (
{expanded && (
{owned.length > 0 && (
{t("address_books.title")}
{owned.map((book) => ( onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} /> ))}
)} {sharedGroups.map((sg) => (
{sg.label}
{sg.books.map((book) => ( onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} /> ))}
))}
)}
); }) ) : ( personalBooks.length > 0 && (
{!collapsed.addressBooks && personalBooks.map((book) => ( onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} /> ))}
) )} {/* Groups section */} {sortedGroups.length > 0 && (
{!collapsed.groups && sortedGroups.map((group) => { const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id; const memberCount = memberCountByGroup[group.id] || 0; return ( ); })}
)} {/* Categories section (from contact keywords) */}
{!collapsed.categories && ( <> {/* No Category item */} {allKeywords.map(([keyword, count]) => { const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword; return ( onSelectCategory({ keyword })} onDropContacts={onDropContactsToCategory} onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined} /> ); })} )}
{/* Shared accounts with address books - only when not already split into per-account groups above (multi-account Pro mode). */} {!multiAccountMode && sharedBookGroups.map((group) => (
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => ( onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} /> ))}
))}
{/* Address book context menu */} {bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => { const book = bookContextMenu.data; const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false; const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false; const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared; const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false; const showSeparator = (canCreate || canRename || canShare) && canDelete; return ( {canCreate && ( { closeBookContextMenu(); onCreateContactInBook(book); }} /> )} {canRename && ( { closeBookContextMenu(); onRenameAddressBook(book); }} /> )} {canShare && ( { closeBookContextMenu(); onShareAddressBook(book); }} /> )} {showSeparator && } {canDelete && ( { closeBookContextMenu(); onDeleteAddressBook(book); }} destructive /> )} ); })()} {/* Keyword (category) context menu */} {keywordContextMenu.data && onRenameKeyword && ( { const kw = keywordContextMenu.data!; closeKeywordContextMenu(); onRenameKeyword(kw); }} /> )} {/* Group context menu */} {groupContextMenu.data && ( { closeGroupContextMenu(); onEditGroup?.(groupContextMenu.data!.id); }} /> { closeGroupContextMenu(); onDeleteGroup?.(groupContextMenu.data!.id); }} destructive /> )}
); } function CategoryItem({ keyword, count, isActive, onSelect, onDropContacts, onContextMenu, }: { keyword: string; count: number; isActive: boolean; onSelect: () => void; onDropContacts?: (contactIds: string[], keyword: string) => void; onContextMenu?: (e: React.MouseEvent) => void; }) { const [isDragOver, setIsDragOver] = useState(false); const handleDragOver = useCallback((e: DragEvent) => { if (!e.dataTransfer.types.includes("application/x-contact-ids")) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setIsDragOver(true); }, []); const handleDragLeave = useCallback(() => { setIsDragOver(false); }, []); const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); setIsDragOver(false); const data = e.dataTransfer.getData("application/x-contact-ids"); if (!data || !onDropContacts) return; try { const contactIds = JSON.parse(data) as string[]; if (contactIds.length > 0) { onDropContacts(contactIds, keyword); } } catch { // ignore invalid data } }, [keyword, onDropContacts]); return ( ); } function AddressBookItem({ book, isActive, contactCount, onSelect, onDropContacts, onContextMenu, }: { book: AddressBook; isActive: boolean; contactCount: number; onSelect: () => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onContextMenu?: (e: React.MouseEvent) => void; }) { const [isDragOver, setIsDragOver] = useState(false); const handleDragOver = useCallback((e: DragEvent) => { if (!e.dataTransfer.types.includes("application/x-contact-ids")) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setIsDragOver(true); }, []); const handleDragLeave = useCallback(() => { setIsDragOver(false); }, []); const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); setIsDragOver(false); const data = e.dataTransfer.getData("application/x-contact-ids"); if (!data || !onDropContacts) return; try { const contactIds = JSON.parse(data) as string[]; if (contactIds.length > 0) { onDropContacts(contactIds, book); } } catch { // ignore invalid data } }, [book, onDropContacts]); return ( ); }