feat: enhance contact management with import functionality and keyword filtering
This commit is contained in:
@@ -611,6 +611,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const visibleEvents = useMemo(() =>
|
||||||
events.filter((e) => {
|
events.filter((e) => {
|
||||||
|
if (!e.calendarIds) return false;
|
||||||
const calIds = Object.keys(e.calendarIds);
|
const calIds = Object.keys(e.calendarIds);
|
||||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ContactForm } from "@/components/contacts/contact-form";
|
|||||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
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 { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -73,10 +74,12 @@ export default function ContactsPage() {
|
|||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -84,10 +87,10 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Panel resize state - sidebar (categories)
|
// Panel resize state - sidebar (categories)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; }
|
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
|
||||||
});
|
});
|
||||||
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
||||||
const sidebarDragStartWidth = useRef(180);
|
const sidebarDragStartWidth = useRef(256);
|
||||||
|
|
||||||
// Panel resize state - contact list
|
// Panel resize state - contact list
|
||||||
const [listWidth, setListWidth] = useState(() => {
|
const [listWidth, setListWidth] = useState(() => {
|
||||||
@@ -125,21 +128,17 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Contacts to display based on active category
|
// Contacts to display based on active category
|
||||||
const displayedContacts = useMemo(() => {
|
const displayedContacts = useMemo(() => {
|
||||||
if (activeCategory === "all") return individuals.filter(c => !c.isShared);
|
if (activeCategory === "all") return individuals;
|
||||||
if ("addressBookId" in activeCategory) {
|
if ("addressBookId" in activeCategory) {
|
||||||
const bookId = activeCategory.addressBookId;
|
const bookId = activeCategory.addressBookId;
|
||||||
return individuals.filter(c => {
|
return individuals.filter(c => {
|
||||||
if (!c.addressBookIds) return false;
|
if (!c.addressBookIds) return false;
|
||||||
// Check both namespaced (accountId:bookId) and raw bookId
|
return c.addressBookIds[bookId] === true;
|
||||||
if (c.addressBookIds[bookId]) return true;
|
|
||||||
// For shared contacts, match namespaced id
|
|
||||||
if (c.isShared && c.accountId) {
|
|
||||||
const namespacedId = `${c.accountId}:${Object.keys(c.addressBookIds).find(k => c.addressBookIds[k])}`;
|
|
||||||
return namespacedId === bookId;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return individuals.filter(c => c.keywords?.[activeCategory.keyword]);
|
||||||
|
}
|
||||||
// Show members of the selected group
|
// Show members of the selected group
|
||||||
return getGroupMembers(activeCategory.groupId);
|
return getGroupMembers(activeCategory.groupId);
|
||||||
}, [activeCategory, individuals, getGroupMembers]);
|
}, [activeCategory, individuals, getGroupMembers]);
|
||||||
@@ -151,6 +150,9 @@ export default function ContactsPage() {
|
|||||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||||
return book?.name || t("tabs.all");
|
return book?.name || t("tabs.all");
|
||||||
}
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return activeCategory.keyword;
|
||||||
|
}
|
||||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||||
}, [activeCategory, contacts, addressBooks, t]);
|
}, [activeCategory, contacts, addressBooks, t]);
|
||||||
@@ -160,6 +162,7 @@ export default function ContactsPage() {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
if (typeof category === "object" && "groupId" in category) {
|
if (typeof category === "object" && "groupId" in category) {
|
||||||
setSelectedGroupId(category.groupId);
|
setSelectedGroupId(category.groupId);
|
||||||
|
setView("group-detail");
|
||||||
} else {
|
} else {
|
||||||
setSelectedGroupId(null);
|
setSelectedGroupId(null);
|
||||||
}
|
}
|
||||||
@@ -179,6 +182,13 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, moveContactToAddressBook, t]);
|
}, [client, moveContactToAddressBook, t]);
|
||||||
|
|
||||||
|
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||||
|
return importContacts(
|
||||||
|
supportsSync && client ? client : null,
|
||||||
|
importedContacts
|
||||||
|
);
|
||||||
|
}, [supportsSync, client, importContacts]);
|
||||||
|
|
||||||
const handleSelectContact = (id: string) => {
|
const handleSelectContact = (id: string) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -273,6 +283,35 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
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 () => {
|
const handleDeleteGroup = async () => {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
|
|
||||||
@@ -416,7 +455,6 @@ export default function ContactsPage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
setActiveCategory("all");
|
|
||||||
setView("detail");
|
setView("detail");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -548,17 +586,20 @@ export default function ContactsPage() {
|
|||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onImport={() => setShowImportDialog(true)}
|
||||||
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||||
onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))}
|
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||||
onResizeEnd={() => {
|
onResizeEnd={() => {
|
||||||
setIsSidebarResizing(false);
|
setIsSidebarResizing(false);
|
||||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }}
|
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -642,6 +683,17 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
{showImportDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||||
|
<ContactImportDialog
|
||||||
|
existingContacts={contacts}
|
||||||
|
onImport={handleImportContacts}
|
||||||
|
onClose={() => setShowImportDialog(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { BookUser, Users, Plus, UserPlus, Share2, Book } from "lucide-react";
|
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName } from "@/stores/contact-store";
|
||||||
|
|
||||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string };
|
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string };
|
||||||
|
|
||||||
interface ContactsSidebarProps {
|
interface ContactsSidebarProps {
|
||||||
groups: ContactCard[];
|
groups: ContactCard[];
|
||||||
@@ -18,10 +20,30 @@ interface ContactsSidebarProps {
|
|||||||
onSelectCategory: (category: ContactCategory) => void;
|
onSelectCategory: (category: ContactCategory) => void;
|
||||||
onCreateGroup: () => void;
|
onCreateGroup: () => void;
|
||||||
onCreateContact: () => void;
|
onCreateContact: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
|
onEditGroup?: (groupId: string) => void;
|
||||||
|
onDeleteGroup?: (groupId: string) => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
|
||||||
|
|
||||||
|
function loadCollapsed(): Record<string, boolean> {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(COLLAPSED_KEY);
|
||||||
|
return v ? JSON.parse(v) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsed(state: Record<string, boolean>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, JSON.stringify(state));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function ContactsSidebar({
|
export function ContactsSidebar({
|
||||||
groups,
|
groups,
|
||||||
individuals,
|
individuals,
|
||||||
@@ -30,10 +52,42 @@ export function ContactsSidebar({
|
|||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
onCreateGroup,
|
onCreateGroup,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
|
onImport,
|
||||||
|
onEditGroup,
|
||||||
|
onDeleteGroup,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuBtnRef = useRef<HTMLButtonElement>(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(() => {
|
const sortedGroups = useMemo(() => {
|
||||||
return [...groups].sort((a, b) =>
|
return [...groups].sort((a, b) =>
|
||||||
@@ -73,25 +127,96 @@ export function ContactsSidebar({
|
|||||||
if (!contact.addressBookIds) continue;
|
if (!contact.addressBookIds) continue;
|
||||||
for (const bookId of Object.keys(contact.addressBookIds)) {
|
for (const bookId of Object.keys(contact.addressBookIds)) {
|
||||||
if (!contact.addressBookIds[bookId]) continue;
|
if (!contact.addressBookIds[bookId]) continue;
|
||||||
// Build the full namespaced key
|
counts[bookId] = (counts[bookId] || 0) + 1;
|
||||||
const key = contact.isShared && contact.accountId ? `${contact.accountId}:${bookId}` : bookId;
|
|
||||||
counts[key] = (counts[key] || 0) + 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [individuals]);
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Auto-collect keywords from all contacts
|
||||||
|
const allKeywords = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Resolve actual group member counts against living contacts
|
||||||
|
const memberCountByGroup = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
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 (
|
return (
|
||||||
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||||
<Button size="icon" variant="ghost" onClick={onCreateContact} className="h-7 w-7 flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<UserPlus className="w-4 h-4" />
|
<Button
|
||||||
</Button>
|
ref={menuBtnRef}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowMenu(v => !v)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showMenu && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
{t("create_new")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||||
|
>
|
||||||
|
<UsersRound className="w-4 h-4" />
|
||||||
|
{t("groups.create")}
|
||||||
|
</button>
|
||||||
|
{onImport && (
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onImport(); }}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{t("import.title")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{/* All contacts */}
|
{/* All contacts */}
|
||||||
<button
|
<button
|
||||||
@@ -107,19 +232,27 @@ export function ContactsSidebar({
|
|||||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{t("tabs.all")}</span>
|
<span className="truncate">{t("tabs.all")}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{individuals.filter(c => !c.isShared).length}
|
{individuals.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Personal address books */}
|
{/* My Address Books */}
|
||||||
{personalBooks.length > 0 && (
|
{personalBooks.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
|
onClick={() => toggleSection("addressBooks")}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed.addressBooks ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t("address_books.title")}
|
{t("address_books.title")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
{personalBooks.map((book) => (
|
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
book={book}
|
book={book}
|
||||||
@@ -133,29 +266,33 @@ export function ContactsSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Groups section */}
|
{/* Groups section */}
|
||||||
{(sortedGroups.length > 0) && (
|
{sortedGroups.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
|
onClick={() => toggleSection("groups")}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed.groups ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t("tabs.groups")}
|
{t("tabs.groups")}
|
||||||
</span>
|
</span>
|
||||||
<Button size="icon" variant="ghost" onClick={onCreateGroup} className="h-5 w-5">
|
</button>
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedGroups.map((group) => {
|
{!collapsed.groups && sortedGroups.map((group) => {
|
||||||
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
||||||
const memberCount = group.members
|
const memberCount = memberCountByGroup[group.id] || 0;
|
||||||
? Object.values(group.members).filter(Boolean).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||||
|
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted"
|
: "text-foreground/80 hover:bg-muted"
|
||||||
@@ -173,35 +310,66 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedGroups.length === 0 && (
|
{/* Categories section (from contact keywords) */}
|
||||||
<div className="mt-2 px-3">
|
{allKeywords.length > 0 && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="mt-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<button
|
||||||
{t("tabs.groups")}
|
onClick={() => toggleSection("categories")}
|
||||||
</span>
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onCreateGroup}
|
|
||||||
className="w-full justify-start text-xs text-muted-foreground h-7"
|
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1.5" />
|
{collapsed.categories ? (
|
||||||
{t("groups.create")}
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
</Button>
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t("detail.categories")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed.categories && allKeywords.map(([keyword, count]) => {
|
||||||
|
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={keyword}
|
||||||
|
onClick={() => onSelectCategory({ keyword })}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{keyword}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shared accounts with address books */}
|
{/* Shared accounts with address books */}
|
||||||
{sharedBookGroups.map((group) => (
|
{sharedBookGroups.map((group) => (
|
||||||
<div key={group.accountId} className="mt-2">
|
<div key={group.accountId} className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1">
|
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||||
<Share2 className="w-3 h-3" />
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
{group.accountName}
|
>
|
||||||
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||||
|
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
{group.books.map((book) => (
|
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
book={book}
|
book={book}
|
||||||
@@ -214,6 +382,35 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Group context menu */}
|
||||||
|
{groupContextMenu.data && (
|
||||||
|
<ContextMenu
|
||||||
|
ref={groupMenuRef}
|
||||||
|
isOpen={groupContextMenu.isOpen}
|
||||||
|
position={groupContextMenu.position}
|
||||||
|
onClose={closeGroupContextMenu}
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("groups.edit")}
|
||||||
|
onClick={() => {
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onEditGroup?.(groupContextMenu.data!.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("form.delete")}
|
||||||
|
onClick={() => {
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onDeleteGroup?.(groupContextMenu.data!.id);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -266,7 +463,7 @@ function AddressBookItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted",
|
: "text-foreground/80 hover:bg-muted",
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export function CalendarManagementSettings() {
|
|||||||
const buildCalDavUrl = (calendarId: string) => {
|
const buildCalDavUrl = (calendarId: string) => {
|
||||||
if (!serverUrl || !username) return null;
|
if (!serverUrl || !username) return null;
|
||||||
const base = serverUrl.replace(/\/$/, '');
|
const base = serverUrl.replace(/\/$/, '');
|
||||||
return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
return `${base}/dav/cal/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyUrl = async (url: string) => {
|
const handleCopyUrl = async (url: string) => {
|
||||||
|
|||||||
+5
-2
@@ -2479,6 +2479,9 @@ export class JMAPClient {
|
|||||||
...contact,
|
...contact,
|
||||||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||||||
originalId: contact.id,
|
originalId: contact.id,
|
||||||
|
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
||||||
|
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
||||||
|
) : contact.addressBookIds),
|
||||||
accountId,
|
accountId,
|
||||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
isShared: !isPrimary,
|
isShared: !isPrimary,
|
||||||
@@ -2851,8 +2854,8 @@ export class JMAPClient {
|
|||||||
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
||||||
originalId: event.id,
|
originalId: event.id,
|
||||||
originalCalendarIds: event.calendarIds,
|
originalCalendarIds: event.calendarIds,
|
||||||
calendarIds: isPrimary ? event.calendarIds : Object.fromEntries(
|
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
|
||||||
Object.entries(event.calendarIds).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
||||||
),
|
),
|
||||||
accountId,
|
accountId,
|
||||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||||
|
|||||||
@@ -1473,11 +1473,12 @@
|
|||||||
"title": "Geteilt"
|
"title": "Geteilt"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Verzeichnisse",
|
"title": "Meine Adressbücher",
|
||||||
|
"shared_prefix": "Geteilt: {name}",
|
||||||
"moved": "Kontakt verschoben nach {name}",
|
"moved": "Kontakt verschoben nach {name}",
|
||||||
"moved_plural": "{count} Kontakte verschoben nach {name}",
|
"moved_plural": "{count} Kontakte verschoben nach {name}",
|
||||||
"move_failed": "Kontakt konnte nicht verschoben werden",
|
"move_failed": "Kontakt konnte nicht verschoben werden",
|
||||||
"address_book": "Verzeichnis"
|
"address_book": "Adressbuch"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-Mail-Adressen",
|
"emails": "E-Mail-Adressen",
|
||||||
|
|||||||
@@ -1473,11 +1473,12 @@
|
|||||||
"title": "Shared"
|
"title": "Shared"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Directories",
|
"title": "My Address Books",
|
||||||
|
"shared_prefix": "Shared: {name}",
|
||||||
"moved": "Contact moved to {name}",
|
"moved": "Contact moved to {name}",
|
||||||
"moved_plural": "{count} contacts moved to {name}",
|
"moved_plural": "{count} contacts moved to {name}",
|
||||||
"move_failed": "Failed to move contact",
|
"move_failed": "Failed to move contact",
|
||||||
"address_book": "Directory"
|
"address_book": "Address Book"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Email Addresses",
|
"emails": "Email Addresses",
|
||||||
|
|||||||
@@ -1473,11 +1473,12 @@
|
|||||||
"title": "Compartidos"
|
"title": "Compartidos"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Directorios",
|
"title": "Mis Libretas de Direcciones",
|
||||||
|
"shared_prefix": "Compartido: {name}",
|
||||||
"moved": "Contacto movido a {name}",
|
"moved": "Contacto movido a {name}",
|
||||||
"moved_plural": "{count} contactos movidos a {name}",
|
"moved_plural": "{count} contactos movidos a {name}",
|
||||||
"move_failed": "Error al mover el contacto",
|
"move_failed": "Error al mover el contacto",
|
||||||
"address_book": "Directorio"
|
"address_book": "Libreta de direcciones"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Direcciones de correo",
|
"emails": "Direcciones de correo",
|
||||||
|
|||||||
@@ -1473,11 +1473,12 @@
|
|||||||
"title": "Partagés"
|
"title": "Partagés"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Répertoires",
|
"title": "Mes Carnets d'adresses",
|
||||||
|
"shared_prefix": "Partagé : {name}",
|
||||||
"moved": "Contact déplacé vers {name}",
|
"moved": "Contact déplacé vers {name}",
|
||||||
"moved_plural": "{count} contacts déplacés vers {name}",
|
"moved_plural": "{count} contacts déplacés vers {name}",
|
||||||
"move_failed": "Échec du déplacement du contact",
|
"move_failed": "Échec du déplacement du contact",
|
||||||
"address_book": "Répertoire"
|
"address_book": "Carnet d'adresses"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Adresses e-mail",
|
"emails": "Adresses e-mail",
|
||||||
|
|||||||
@@ -1473,7 +1473,8 @@
|
|||||||
"title": "Condivisi"
|
"title": "Condivisi"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Rubriche",
|
"title": "Le mie Rubriche",
|
||||||
|
"shared_prefix": "Condiviso: {name}",
|
||||||
"moved": "Contatto spostato in {name}",
|
"moved": "Contatto spostato in {name}",
|
||||||
"moved_plural": "{count} contatti spostati in {name}",
|
"moved_plural": "{count} contatti spostati in {name}",
|
||||||
"move_failed": "Impossibile spostare il contatto",
|
"move_failed": "Impossibile spostare il contatto",
|
||||||
|
|||||||
@@ -1473,11 +1473,12 @@
|
|||||||
"title": "共有"
|
"title": "共有"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "ディレクトリ",
|
"title": "マイアドレス帳",
|
||||||
|
"shared_prefix": "共有: {name}",
|
||||||
"moved": "連絡先を {name} に移動しました",
|
"moved": "連絡先を {name} に移動しました",
|
||||||
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
||||||
"move_failed": "連絡先の移動に失敗しました",
|
"move_failed": "連絡先の移動に失敗しました",
|
||||||
"address_book": "ディレクトリ"
|
"address_book": "アドレス帳"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "メールアドレス",
|
"emails": "メールアドレス",
|
||||||
|
|||||||
@@ -1473,7 +1473,8 @@
|
|||||||
"title": "Gedeeld"
|
"title": "Gedeeld"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Adresboeken",
|
"title": "Mijn Adresboeken",
|
||||||
|
"shared_prefix": "Gedeeld: {name}",
|
||||||
"moved": "Contact verplaatst naar {name}",
|
"moved": "Contact verplaatst naar {name}",
|
||||||
"moved_plural": "{count} contacten verplaatst naar {name}",
|
"moved_plural": "{count} contacten verplaatst naar {name}",
|
||||||
"move_failed": "Verplaatsen van contact mislukt",
|
"move_failed": "Verplaatsen van contact mislukt",
|
||||||
|
|||||||
@@ -1473,11 +1473,12 @@
|
|||||||
"title": "Compartilhados"
|
"title": "Compartilhados"
|
||||||
},
|
},
|
||||||
"address_books": {
|
"address_books": {
|
||||||
"title": "Diretórios",
|
"title": "Meus Catálogos de Endereços",
|
||||||
|
"shared_prefix": "Compartilhado: {name}",
|
||||||
"moved": "Contato movido para {name}",
|
"moved": "Contato movido para {name}",
|
||||||
"moved_plural": "{count} contatos movidos para {name}",
|
"moved_plural": "{count} contatos movidos para {name}",
|
||||||
"move_failed": "Falha ao mover o contato",
|
"move_failed": "Falha ao mover o contato",
|
||||||
"address_book": "Diretório"
|
"address_book": "Catálogo de endereços"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Endereços de e-mail",
|
"emails": "Endereços de e-mail",
|
||||||
|
|||||||
+64
-17
@@ -87,7 +87,39 @@ interface ContactStore {
|
|||||||
|
|
||||||
export const useContactStore = create<ContactStore>()(
|
export const useContactStore = create<ContactStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => {
|
||||||
|
|
||||||
|
// Clean group member references when contacts are removed
|
||||||
|
function cleanGroupMembers(contacts: ContactCard[], removedIds: Set<string>): ContactCard[] {
|
||||||
|
// Collect uid/id variants of removed contacts for matching
|
||||||
|
const removedKeys = new Set<string>();
|
||||||
|
for (const c of contacts) {
|
||||||
|
if (!removedIds.has(c.id)) continue;
|
||||||
|
removedKeys.add(c.id);
|
||||||
|
if (c.uid) {
|
||||||
|
removedKeys.add(c.uid);
|
||||||
|
const bare = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
|
||||||
|
removedKeys.add(bare);
|
||||||
|
}
|
||||||
|
if (c.originalId) removedKeys.add(c.originalId);
|
||||||
|
}
|
||||||
|
return contacts.map(c => {
|
||||||
|
if (c.kind !== 'group' || !c.members) return c;
|
||||||
|
let changed = false;
|
||||||
|
const newMembers: Record<string, boolean> = {};
|
||||||
|
for (const [key, val] of Object.entries(c.members)) {
|
||||||
|
const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key;
|
||||||
|
if (removedKeys.has(key) || removedKeys.has(bareKey)) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
newMembers[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? { ...c, members: newMembers } : c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ({
|
||||||
contacts: [],
|
contacts: [],
|
||||||
addressBooks: [],
|
addressBooks: [],
|
||||||
selectedContactId: null,
|
selectedContactId: null,
|
||||||
@@ -170,10 +202,14 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
const originalId = contact?.originalId || id;
|
const originalId = contact?.originalId || id;
|
||||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||||
await client.deleteContact(originalId, accountId);
|
await client.deleteContact(originalId, accountId);
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
contacts: state.contacts.filter(c => c.id !== id),
|
const removedIds = new Set([id]);
|
||||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
const cleaned = cleanGroupMembers(state.contacts, removedIds);
|
||||||
}));
|
return {
|
||||||
|
contacts: cleaned.filter(c => c.id !== id),
|
||||||
|
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
||||||
set({ error: msg });
|
set({ error: msg });
|
||||||
@@ -191,10 +227,14 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
deleteLocalContact: (id) => set((state) => ({
|
deleteLocalContact: (id) => set((state) => {
|
||||||
contacts: state.contacts.filter(c => c.id !== id),
|
const removedIds = new Set([id]);
|
||||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
const cleaned = cleanGroupMembers(state.contacts, removedIds);
|
||||||
})),
|
return {
|
||||||
|
contacts: cleaned.filter(c => c.id !== id),
|
||||||
|
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
setSelectedContact: (id) => set({ selectedContactId: id }),
|
setSelectedContact: (id) => set({ selectedContactId: id }),
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
@@ -456,11 +496,14 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
contacts: state.contacts.filter(c => !deletedIds.has(c.id)),
|
const cleaned = cleanGroupMembers(state.contacts, deletedIds);
|
||||||
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
|
return {
|
||||||
selectedContactIds: new Set<string>(),
|
contacts: cleaned.filter(c => !deletedIds.has(c.id)),
|
||||||
}));
|
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
|
||||||
|
selectedContactIds: new Set<string>(),
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
bulkAddToGroup: async (client, groupId, contactIds) => {
|
bulkAddToGroup: async (client, groupId, contactIds) => {
|
||||||
@@ -485,9 +528,11 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
// Same account: just update the addressBookIds
|
// Same account: just update the addressBookIds
|
||||||
if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) {
|
if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) {
|
||||||
await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId);
|
await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId);
|
||||||
|
const isTargetPrimary = !targetAccountId || targetAccountId === primaryAccountId;
|
||||||
|
const localBookId = isTargetPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
c.id === id ? { ...c, addressBookIds: { [targetBookOriginalId]: true } } : c
|
c.id === id ? { ...c, addressBookIds: { [localBookId]: true } } : c
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
@@ -501,6 +546,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
const isPrimary = !targetAccountId || targetAccountId === primaryAccountId;
|
const isPrimary = !targetAccountId || targetAccountId === primaryAccountId;
|
||||||
|
const localBookId = isPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c => {
|
contacts: state.contacts.map(c => {
|
||||||
if (c.id !== id) return c;
|
if (c.id !== id) return c;
|
||||||
@@ -511,7 +557,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
accountName: addressBook.accountName || targetAccountId,
|
accountName: addressBook.accountName || targetAccountId,
|
||||||
isShared: !isPrimary,
|
isShared: !isPrimary,
|
||||||
addressBookIds: { [targetBookOriginalId]: true },
|
addressBookIds: { [localBookId]: true },
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -544,7 +590,8 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
return imported;
|
return imported;
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'contact-storage',
|
name: 'contact-storage',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user