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(() => 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));
}), }),
+66 -14
View File
@@ -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>
); );
} }
+245 -48
View File
@@ -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
View File
@@ -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),
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+2 -1
View File
@@ -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",
+3 -2
View File
@@ -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": "メールアドレス",
+2 -1
View File
@@ -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",
+3 -2
View File
@@ -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
View File
@@ -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) => ({