feat: enhance contact management with import functionality and keyword filtering

This commit is contained in:
Linus Rath
2026-03-19 08:38:59 +01:00
parent 0fcc932e66
commit 9b3a47f9be
14 changed files with 404 additions and 96 deletions
+1
View File
@@ -611,6 +611,7 @@ export default function CalendarPage() {
const visibleEvents = useMemo(() =>
events.filter((e) => {
if (!e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id));
}),
+66 -14
View File
@@ -13,6 +13,7 @@ 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 } from "@/stores/auth-store";
@@ -73,10 +74,12 @@ export default function ContactsPage() {
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
importContacts,
} = useContactStore();
const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -84,10 +87,10 @@ export default function ContactsPage() {
// Panel resize state - sidebar (categories)
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 sidebarDragStartWidth = useRef(180);
const sidebarDragStartWidth = useRef(256);
// Panel resize state - contact list
const [listWidth, setListWidth] = useState(() => {
@@ -125,21 +128,17 @@ export default function ContactsPage() {
// Contacts to display based on active category
const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals.filter(c => !c.isShared);
if (activeCategory === "all") return individuals;
if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId;
return individuals.filter(c => {
if (!c.addressBookIds) return false;
// Check both namespaced (accountId:bookId) and raw bookId
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;
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]);
@@ -151,6 +150,9 @@ export default function ContactsPage() {
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]);
@@ -160,6 +162,7 @@ export default function ContactsPage() {
clearSelection();
if (typeof category === "object" && "groupId" in category) {
setSelectedGroupId(category.groupId);
setView("group-detail");
} else {
setSelectedGroupId(null);
}
@@ -179,6 +182,13 @@ export default function ContactsPage() {
}
}, [client, moveContactToAddressBook, t]);
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
const handleSelectContact = (id: string) => {
setSelectedContact(id);
clearSelection();
@@ -273,6 +283,35 @@ export default function ContactsPage() {
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;
@@ -416,7 +455,6 @@ export default function ContactsPage() {
isMobile={isMobile}
onSelectMember={(id) => {
setSelectedContact(id);
setActiveCategory("all");
setView("detail");
}}
/>
@@ -548,17 +586,20 @@ export default function ContactsPage() {
onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew}
onImport={() => setShowImportDialog(true)}
onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts}
/>
</div>
<ResizeHandle
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={() => {
setIsSidebarResizing(false);
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} />
<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>
);
}
+245 -48
View File
@@ -1,14 +1,16 @@
"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 { 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 { 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";
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string };
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string };
interface ContactsSidebarProps {
groups: ContactCard[];
@@ -18,10 +20,30 @@ interface ContactsSidebarProps {
onSelectCategory: (category: ContactCategory) => void;
onCreateGroup: () => void;
onCreateContact: () => void;
onImport?: () => void;
onEditGroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
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({
groups,
individuals,
@@ -30,10 +52,42 @@ export function ContactsSidebar({
onSelectCategory,
onCreateGroup,
onCreateContact,
onImport,
onEditGroup,
onDeleteGroup,
onDropContacts,
className,
}: ContactsSidebarProps) {
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(() => {
return [...groups].sort((a, b) =>
@@ -73,25 +127,96 @@ export function ContactsSidebar({
if (!contact.addressBookIds) continue;
for (const bookId of Object.keys(contact.addressBookIds)) {
if (!contact.addressBookIds[bookId]) continue;
// Build the full namespaced key
const key = contact.isShared && contact.accountId ? `${contact.accountId}:${bookId}` : bookId;
counts[key] = (counts[key] || 0) + 1;
counts[bookId] = (counts[bookId] || 0) + 1;
}
}
return counts;
}, [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 (
<div className={cn("flex flex-col h-full bg-secondary", className)}>
{/* Header */}
<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>
<Button size="icon" variant="ghost" onClick={onCreateContact} className="h-7 w-7 flex-shrink-0">
<UserPlus className="w-4 h-4" />
</Button>
<div className="relative flex-shrink-0">
<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>
{/* Categories */}
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-1">
{/* All contacts */}
<button
@@ -107,19 +232,27 @@ export function ContactsSidebar({
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("tabs.all")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{individuals.filter(c => !c.isShared).length}
{individuals.length}
</span>
</button>
{/* Personal address books */}
{/* My Address Books */}
{personalBooks.length > 0 && (
<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">
{t("address_books.title")}
</span>
</div>
{personalBooks.map((book) => (
</button>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
key={book.id}
book={book}
@@ -133,29 +266,33 @@ export function ContactsSidebar({
)}
{/* Groups section */}
{(sortedGroups.length > 0) && (
{sortedGroups.length > 0 && (
<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">
{t("tabs.groups")}
</span>
<Button size="icon" variant="ghost" onClick={onCreateGroup} className="h-5 w-5">
<Plus className="w-3 h-3" />
</Button>
</div>
</button>
{sortedGroups.map((group) => {
{!collapsed.groups && sortedGroups.map((group) => {
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
const memberCount = group.members
? Object.values(group.members).filter(Boolean).length
: 0;
const memberCount = memberCountByGroup[group.id] || 0;
return (
<button
key={group.id}
onClick={() => onSelectCategory({ groupId: group.id })}
onContextMenu={(e) => openGroupContextMenu(e, group)}
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
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
@@ -173,35 +310,66 @@ export function ContactsSidebar({
</div>
)}
{sortedGroups.length === 0 && (
<div className="mt-2 px-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("tabs.groups")}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={onCreateGroup}
className="w-full justify-start text-xs text-muted-foreground h-7"
{/* Categories section (from contact keywords) */}
{allKeywords.length > 0 && (
<div className="mt-2">
<button
onClick={() => toggleSection("categories")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
>
<Plus className="w-3 h-3 mr-1.5" />
{t("groups.create")}
</Button>
{collapsed.categories ? (
<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">
{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>
)}
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{group.accountName}
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
>
{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>
</div>
{group.books.map((book) => (
</button>
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem
key={book.id}
book={book}
@@ -214,6 +382,35 @@ export function ContactsSidebar({
</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>
);
}
@@ -266,7 +463,7 @@ function AddressBookItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
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
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -296,7 +296,7 @@ export function CalendarManagementSettings() {
const buildCalDavUrl = (calendarId: string) => {
if (!serverUrl || !username) return null;
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) => {
+5 -2
View File
@@ -2479,6 +2479,9 @@ export class JMAPClient {
...contact,
id: isPrimary ? contact.id : `${accountId}:${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,
accountName: account?.name || (isPrimary ? this.username : accountId),
isShared: !isPrimary,
@@ -2851,8 +2854,8 @@ export class JMAPClient {
id: isPrimary ? event.id : `${accountId}:${event.id}`,
originalId: event.id,
originalCalendarIds: event.calendarIds,
calendarIds: isPrimary ? event.calendarIds : Object.fromEntries(
Object.entries(event.calendarIds).map(([calId, v]) => [`${accountId}:${calId}`, v])
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
),
accountId,
accountName: account?.name || (isPrimary ? this.username : accountId),
+3 -2
View File
@@ -1473,11 +1473,12 @@
"title": "Geteilt"
},
"address_books": {
"title": "Verzeichnisse",
"title": "Meine Adressbücher",
"shared_prefix": "Geteilt: {name}",
"moved": "Kontakt verschoben nach {name}",
"moved_plural": "{count} Kontakte verschoben nach {name}",
"move_failed": "Kontakt konnte nicht verschoben werden",
"address_book": "Verzeichnis"
"address_book": "Adressbuch"
},
"detail": {
"emails": "E-Mail-Adressen",
+3 -2
View File
@@ -1473,11 +1473,12 @@
"title": "Shared"
},
"address_books": {
"title": "Directories",
"title": "My Address Books",
"shared_prefix": "Shared: {name}",
"moved": "Contact moved to {name}",
"moved_plural": "{count} contacts moved to {name}",
"move_failed": "Failed to move contact",
"address_book": "Directory"
"address_book": "Address Book"
},
"detail": {
"emails": "Email Addresses",
+3 -2
View File
@@ -1473,11 +1473,12 @@
"title": "Compartidos"
},
"address_books": {
"title": "Directorios",
"title": "Mis Libretas de Direcciones",
"shared_prefix": "Compartido: {name}",
"moved": "Contacto movido a {name}",
"moved_plural": "{count} contactos movidos a {name}",
"move_failed": "Error al mover el contacto",
"address_book": "Directorio"
"address_book": "Libreta de direcciones"
},
"detail": {
"emails": "Direcciones de correo",
+3 -2
View File
@@ -1473,11 +1473,12 @@
"title": "Partagés"
},
"address_books": {
"title": "Répertoires",
"title": "Mes Carnets d'adresses",
"shared_prefix": "Partagé : {name}",
"moved": "Contact déplacé vers {name}",
"moved_plural": "{count} contacts déplacés vers {name}",
"move_failed": "Échec du déplacement du contact",
"address_book": "Répertoire"
"address_book": "Carnet d'adresses"
},
"detail": {
"emails": "Adresses e-mail",
+2 -1
View File
@@ -1473,7 +1473,8 @@
"title": "Condivisi"
},
"address_books": {
"title": "Rubriche",
"title": "Le mie Rubriche",
"shared_prefix": "Condiviso: {name}",
"moved": "Contatto spostato in {name}",
"moved_plural": "{count} contatti spostati in {name}",
"move_failed": "Impossibile spostare il contatto",
+3 -2
View File
@@ -1473,11 +1473,12 @@
"title": "共有"
},
"address_books": {
"title": "ディレクトリ",
"title": "マイアドレス帳",
"shared_prefix": "共有: {name}",
"moved": "連絡先を {name} に移動しました",
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
"move_failed": "連絡先の移動に失敗しました",
"address_book": "ディレクトリ"
"address_book": "アドレス帳"
},
"detail": {
"emails": "メールアドレス",
+2 -1
View File
@@ -1473,7 +1473,8 @@
"title": "Gedeeld"
},
"address_books": {
"title": "Adresboeken",
"title": "Mijn Adresboeken",
"shared_prefix": "Gedeeld: {name}",
"moved": "Contact verplaatst naar {name}",
"moved_plural": "{count} contacten verplaatst naar {name}",
"move_failed": "Verplaatsen van contact mislukt",
+3 -2
View File
@@ -1473,11 +1473,12 @@
"title": "Compartilhados"
},
"address_books": {
"title": "Diretórios",
"title": "Meus Catálogos de Endereços",
"shared_prefix": "Compartilhado: {name}",
"moved": "Contato movido para {name}",
"moved_plural": "{count} contatos movidos para {name}",
"move_failed": "Falha ao mover o contato",
"address_book": "Diretório"
"address_book": "Catálogo de endereços"
},
"detail": {
"emails": "Endereços de e-mail",
+64 -17
View File
@@ -87,7 +87,39 @@ interface ContactStore {
export const useContactStore = create<ContactStore>()(
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: [],
addressBooks: [],
selectedContactId: null,
@@ -170,10 +202,14 @@ export const useContactStore = create<ContactStore>()(
const originalId = contact?.originalId || id;
const accountId = contact?.isShared ? contact.accountId : undefined;
await client.deleteContact(originalId, accountId);
set((state) => ({
contacts: state.contacts.filter(c => c.id !== id),
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
}));
set((state) => {
const removedIds = new Set([id]);
const cleaned = cleanGroupMembers(state.contacts, removedIds);
return {
contacts: cleaned.filter(c => c.id !== id),
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
};
});
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
set({ error: msg });
@@ -191,10 +227,14 @@ export const useContactStore = create<ContactStore>()(
),
})),
deleteLocalContact: (id) => set((state) => ({
contacts: state.contacts.filter(c => c.id !== id),
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
})),
deleteLocalContact: (id) => set((state) => {
const removedIds = new Set([id]);
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 }),
setSearchQuery: (query) => set({ searchQuery: query }),
@@ -456,11 +496,14 @@ export const useContactStore = create<ContactStore>()(
}
}
set((state) => ({
contacts: state.contacts.filter(c => !deletedIds.has(c.id)),
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
selectedContactIds: new Set<string>(),
}));
set((state) => {
const cleaned = cleanGroupMembers(state.contacts, deletedIds);
return {
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) => {
@@ -485,9 +528,11 @@ export const useContactStore = create<ContactStore>()(
// Same account: just update the addressBookIds
if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) {
await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId);
const isTargetPrimary = !targetAccountId || targetAccountId === primaryAccountId;
const localBookId = isTargetPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`;
set((state) => ({
contacts: state.contacts.map(c =>
c.id === id ? { ...c, addressBookIds: { [targetBookOriginalId]: true } } : c
c.id === id ? { ...c, addressBookIds: { [localBookId]: true } } : c
),
}));
} else {
@@ -501,6 +546,7 @@ export const useContactStore = create<ContactStore>()(
// Update local state
const isPrimary = !targetAccountId || targetAccountId === primaryAccountId;
const localBookId = isPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`;
set((state) => ({
contacts: state.contacts.map(c => {
if (c.id !== id) return c;
@@ -511,7 +557,7 @@ export const useContactStore = create<ContactStore>()(
accountId: targetAccountId,
accountName: addressBook.accountName || targetAccountId,
isShared: !isPrimary,
addressBookIds: { [targetBookOriginalId]: true },
addressBookIds: { [localBookId]: true },
};
}),
}));
@@ -544,7 +590,8 @@ export const useContactStore = create<ContactStore>()(
return imported;
},
}),
});
},
{
name: 'contact-storage',
partialize: (state) => ({