feat: Ability to rename address book #152
This commit is contained in:
@@ -13,6 +13,7 @@ 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 { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
@@ -72,12 +73,16 @@ export default function ContactsPage() {
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -631,6 +636,8 @@ export default function ContactsPage() {
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
@@ -725,6 +732,46 @@ export default function ContactsPage() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
{renamingKeyword !== null && (
|
||||
<RenameDialog
|
||||
currentName={renamingKeyword}
|
||||
title={t("rename_category")}
|
||||
label={t("category_name_label")}
|
||||
onCancel={() => setRenamingKeyword(null)}
|
||||
onConfirm={async (newName) => {
|
||||
try {
|
||||
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
|
||||
toast.success(t("category_renamed"));
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
|
||||
setActiveCategory({ keyword: newName.trim() });
|
||||
}
|
||||
setRenamingKeyword(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename category:", err);
|
||||
toast.error(t("category_rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renamingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName={renamingAddressBook.name}
|
||||
title={t("address_books.rename")}
|
||||
label={t("address_books.name_label")}
|
||||
onCancel={() => setRenamingAddressBook(null)}
|
||||
onConfirm={async (newName) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await renameAddressBook(client, renamingAddressBook, newName);
|
||||
toast.success(t("address_books.renamed"));
|
||||
setRenamingAddressBook(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename address book:", err);
|
||||
toast.error(t("address_books.rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
|
||||
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
|
||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
@@ -211,7 +212,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'encryption' && <SmimeSettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{activeTab === 'contacts' && <ContactsSettings />}
|
||||
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{activeTab === 'filters' && <FilterSettings />}
|
||||
{activeTab === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -58,10 +61,15 @@ export function ContactsSidebar({
|
||||
onDeleteGroup,
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
onRenameKeyword,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const router = useRouter();
|
||||
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
|
||||
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
@@ -246,19 +254,32 @@ export function ContactsSidebar({
|
||||
{/* My Address Books */}
|
||||
{personalBooks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<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>
|
||||
</button>
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 flex-1 text-left"
|
||||
>
|
||||
{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>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||
router.push('/settings');
|
||||
}}
|
||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||
title={t("address_books.manage")}
|
||||
>
|
||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
@@ -267,6 +288,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -362,6 +384,7 @@ export function ContactsSidebar({
|
||||
isActive={isActive}
|
||||
onSelect={() => onSelectCategory({ keyword })}
|
||||
onDropContacts={onDropContactsToCategory}
|
||||
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -372,20 +395,33 @@ export function ContactsSidebar({
|
||||
{/* Shared accounts with address books */}
|
||||
{sharedBookGroups.map((group) => (
|
||||
<div key={group.accountId} className="mt-2">
|
||||
<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>
|
||||
</button>
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
||||
>
|
||||
{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>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||
router.push('/settings');
|
||||
}}
|
||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||
title={t("address_books.manage")}
|
||||
>
|
||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
@@ -394,12 +430,53 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Address book context menu */}
|
||||
{bookContextMenu.data && onRenameAddressBook && (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("address_books.rename")}
|
||||
onClick={() => {
|
||||
const book = bookContextMenu.data!;
|
||||
closeBookContextMenu();
|
||||
onRenameAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{/* Keyword (category) context menu */}
|
||||
{keywordContextMenu.data && onRenameKeyword && (
|
||||
<ContextMenu
|
||||
ref={keywordMenuRef}
|
||||
isOpen={keywordContextMenu.isOpen}
|
||||
position={keywordContextMenu.position}
|
||||
onClose={closeKeywordContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("rename_category")}
|
||||
onClick={() => {
|
||||
const kw = keywordContextMenu.data!;
|
||||
closeKeywordContextMenu();
|
||||
onRenameKeyword(kw);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{/* Group context menu */}
|
||||
{groupContextMenu.data && (
|
||||
<ContextMenu
|
||||
@@ -438,12 +515,14 @@ function CategoryItem({
|
||||
isActive,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
keyword: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -476,6 +555,7 @@ function CategoryItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -503,12 +583,14 @@ function AddressBookItem({
|
||||
contactCount,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
book: AddressBook;
|
||||
isActive: boolean;
|
||||
contactCount: number;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -541,6 +623,7 @@ function AddressBookItem({
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, Pencil, Share2, Tag } from "lucide-react";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook } from "@/lib/jmap/types";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: {
|
||||
initial: string;
|
||||
onSave: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const t = useTranslations("contacts.address_books");
|
||||
const tCal = useTranslations("calendar.management");
|
||||
const [name, setName] = useState(initial);
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("name_label")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && isValid) onSave(name.trim());
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => isValid && onSave(name.trim())}
|
||||
disabled={isLoading || !isValid}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{tCal("save")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
|
||||
>
|
||||
{tCal("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressBookManagementSettings() {
|
||||
const t = useTranslations("contacts.address_books");
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && addressBooks.length === 0) {
|
||||
fetchAddressBooks(client);
|
||||
}
|
||||
}, [client, addressBooks.length, fetchAddressBooks]);
|
||||
|
||||
const handleUpdate = async (book: AddressBook, newName: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await renameAddressBook(client, book, newName);
|
||||
setEditingId(null);
|
||||
toast.success(t("renamed"));
|
||||
} catch {
|
||||
toast.error(t("rename_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Group: personal first, then by shared account
|
||||
const personal = addressBooks.filter((b) => !b.isShared);
|
||||
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
|
||||
for (const book of addressBooks) {
|
||||
if (!book.isShared || !book.accountId) continue;
|
||||
const key = book.accountId;
|
||||
const existing = sharedGroups.get(key);
|
||||
if (existing) existing.books.push(book);
|
||||
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
|
||||
}
|
||||
|
||||
const renderBook = (book: AddressBook) => {
|
||||
if (editingId === book.id) {
|
||||
return (
|
||||
<AddressBookEditRow
|
||||
key={book.id}
|
||||
initial={book.name}
|
||||
onSave={(name) => handleUpdate(book, name)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={book.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
|
||||
)}
|
||||
>
|
||||
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{book.name}</span>
|
||||
</div>
|
||||
{book.isDefault && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{t("default")}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canRename && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(book.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("rename")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Collect keywords with counts
|
||||
const keywordCounts: Record<string, number> = {};
|
||||
for (const c of contacts) {
|
||||
if (c.kind === "group" || !c.keywords) continue;
|
||||
for (const [kw, active] of Object.entries(c.keywords)) {
|
||||
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
|
||||
setEditingKeyword(null);
|
||||
toast.success(tContacts("category_renamed"));
|
||||
} catch {
|
||||
toast.error(tContacts("category_rename_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
|
||||
<div className="space-y-2">
|
||||
{personal.map(renderBook)}
|
||||
|
||||
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
|
||||
<div key={accountId} className="mt-4 space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{t("shared_prefix", { name: group.accountName })}
|
||||
</h4>
|
||||
{group.books.map(renderBook)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{addressBooks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mt-8">
|
||||
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
|
||||
<div className="space-y-2">
|
||||
{sortedKeywords.map(([keyword, count]) => {
|
||||
if (editingKeyword === keyword) {
|
||||
return (
|
||||
<AddressBookEditRow
|
||||
key={keyword}
|
||||
initial={keyword}
|
||||
onSave={(name) => handleRenameKeyword(keyword, name)}
|
||||
onCancel={() => setEditingKeyword(null)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={keyword}
|
||||
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
|
||||
>
|
||||
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{keyword}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingKeyword(keyword)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={tContacts("rename_category")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sortedKeywords.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function AdvancedSettings() {
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/stalwartlabs/webmail"
|
||||
href="https://github.com/bulwarkmail/webmail"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -481,6 +481,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||
|
||||
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
|
||||
const book = this.data.addressBooks.find(b => b.id === addressBookId);
|
||||
if (book) Object.assign(book, updates);
|
||||
}
|
||||
|
||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||
return [...this.data.contacts];
|
||||
|
||||
@@ -178,6 +178,7 @@ export interface IJMAPClient {
|
||||
getContactsAccountId(): string;
|
||||
getAddressBooks(): Promise<AddressBook[]>;
|
||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||
getAllContacts(): Promise<ContactCard[]>;
|
||||
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||
|
||||
@@ -2818,6 +2818,35 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
|
||||
const accountId = targetAccountId || this.getContactsAccountId();
|
||||
// Only forward server-settable properties
|
||||
const { name, description, sortOrder, isDefault, color } = updates as Record<string, unknown>;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (description !== undefined) patch.description = description;
|
||||
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
||||
if (isDefault !== undefined) patch.isDefault = isDefault;
|
||||
if (color !== undefined) patch.color = color;
|
||||
|
||||
const response = await this.request([
|
||||
["AddressBook/set", {
|
||||
accountId,
|
||||
update: { [addressBookId]: patch },
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
if (result.notUpdated?.[addressBookId]) {
|
||||
const error = result.notUpdated[addressBookId];
|
||||
throw new Error(error.description || "Failed to update address book");
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error("Failed to update address book");
|
||||
}
|
||||
|
||||
private async fetchPaginatedContacts(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||
"manage_title": "Adressbücher",
|
||||
"manage_description": "Adressbücher umbenennen",
|
||||
"no_address_books": "Keine Adressbücher gefunden",
|
||||
"categories_title": "Kategorien",
|
||||
"categories_description": "Kontaktkategorien umbenennen",
|
||||
"no_categories": "Keine Kategorien gefunden"
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-Mail-Filter",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "Kontakte suchen...",
|
||||
"create_new": "Neuer Kontakt",
|
||||
"no_category": "Ohne Kategorie",
|
||||
"rename_category": "Kategorie umbenennen",
|
||||
"category_name_label": "Kategoriename",
|
||||
"category_renamed": "Kategorie umbenannt",
|
||||
"category_rename_failed": "Kategorie konnte nicht umbenannt werden",
|
||||
"category_added": "Kontakt zu {name} hinzugefügt",
|
||||
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
|
||||
"empty_state": "Keine Kontakte",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "Kontakt verschoben nach {name}",
|
||||
"moved_plural": "{count} Kontakte verschoben nach {name}",
|
||||
"move_failed": "Kontakt konnte nicht verschoben werden",
|
||||
"address_book": "Adressbuch"
|
||||
"address_book": "Adressbuch",
|
||||
"rename": "Adressbuch umbenennen",
|
||||
"name_label": "Name des Adressbuchs",
|
||||
"renamed": "Adressbuch umbenannt",
|
||||
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
|
||||
"default": "Standard",
|
||||
"manage": "Adressbücher verwalten"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-Mail-Adressen",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||
"manage_title": "Address Books",
|
||||
"manage_description": "Rename your address books",
|
||||
"no_address_books": "No address books found",
|
||||
"categories_title": "Categories",
|
||||
"categories_description": "Rename contact categories",
|
||||
"no_categories": "No categories found"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Email Filters",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "Search contacts...",
|
||||
"create_new": "New Contact",
|
||||
"no_category": "No Category",
|
||||
"rename_category": "Rename category",
|
||||
"category_name_label": "Category name",
|
||||
"category_renamed": "Category renamed",
|
||||
"category_rename_failed": "Failed to rename category",
|
||||
"category_added": "Contact added to {name}",
|
||||
"category_added_plural": "{count} contacts added to {name}",
|
||||
"empty_state": "No contacts yet",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "Contact moved to {name}",
|
||||
"moved_plural": "{count} contacts moved to {name}",
|
||||
"move_failed": "Failed to move contact",
|
||||
"address_book": "Address Book"
|
||||
"address_book": "Address Book",
|
||||
"rename": "Rename address book",
|
||||
"name_label": "Address book name",
|
||||
"renamed": "Address book renamed",
|
||||
"rename_failed": "Failed to rename address book",
|
||||
"default": "Default",
|
||||
"manage": "Manage address books"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Email Addresses",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||
"manage_title": "Libretas de direcciones",
|
||||
"manage_description": "Renombrar tus libretas de direcciones",
|
||||
"no_address_books": "No se encontraron libretas de direcciones",
|
||||
"categories_title": "Categorías",
|
||||
"categories_description": "Renombrar categorías de contactos",
|
||||
"no_categories": "No se encontraron categorías"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de correo",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "Buscar contactos...",
|
||||
"create_new": "Nuevo contacto",
|
||||
"no_category": "Sin categoría",
|
||||
"rename_category": "Renombrar categoría",
|
||||
"category_name_label": "Nombre de la categoría",
|
||||
"category_renamed": "Categoría renombrada",
|
||||
"category_rename_failed": "Error al renombrar la categoría",
|
||||
"category_added": "Contacto añadido a {name}",
|
||||
"category_added_plural": "{count} contactos añadidos a {name}",
|
||||
"empty_state": "No hay contactos",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "Contacto movido a {name}",
|
||||
"moved_plural": "{count} contactos movidos a {name}",
|
||||
"move_failed": "Error al mover el contacto",
|
||||
"address_book": "Libreta de direcciones"
|
||||
"address_book": "Libreta de direcciones",
|
||||
"rename": "Renombrar libreta de direcciones",
|
||||
"name_label": "Nombre de la libreta de direcciones",
|
||||
"renamed": "Libreta de direcciones renombrada",
|
||||
"rename_failed": "Error al renombrar la libreta de direcciones",
|
||||
"default": "Predeterminada",
|
||||
"manage": "Administrar libretas de direcciones"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Direcciones de correo",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||
"manage_title": "Carnets d'adresses",
|
||||
"manage_description": "Renommer vos carnets d'adresses",
|
||||
"no_address_books": "Aucun carnet d'adresses trouvé",
|
||||
"categories_title": "Catégories",
|
||||
"categories_description": "Renommer les catégories de contacts",
|
||||
"no_categories": "Aucune catégorie trouvée"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres de courrier",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "Rechercher des contacts...",
|
||||
"create_new": "Nouveau contact",
|
||||
"no_category": "Sans catégorie",
|
||||
"rename_category": "Renommer la catégorie",
|
||||
"category_name_label": "Nom de la catégorie",
|
||||
"category_renamed": "Catégorie renommée",
|
||||
"category_rename_failed": "Échec du renommage de la catégorie",
|
||||
"category_added": "Contact ajouté à {name}",
|
||||
"category_added_plural": "{count} contacts ajoutés à {name}",
|
||||
"empty_state": "Aucun contact",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"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": "Carnet d'adresses"
|
||||
"address_book": "Carnet d'adresses",
|
||||
"rename": "Renommer le carnet d'adresses",
|
||||
"name_label": "Nom du carnet d'adresses",
|
||||
"renamed": "Carnet d'adresses renommé",
|
||||
"rename_failed": "Échec du renommage du carnet d'adresses",
|
||||
"default": "Par défaut",
|
||||
"manage": "Gérer les carnets d'adresses"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresses e-mail",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||
"manage_title": "Rubriche",
|
||||
"manage_description": "Rinomina le tue rubriche",
|
||||
"no_address_books": "Nessuna rubrica trovata",
|
||||
"categories_title": "Categorie",
|
||||
"categories_description": "Rinomina le categorie dei contatti",
|
||||
"no_categories": "Nessuna categoria trovata"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtri email",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "Cerca contatti...",
|
||||
"create_new": "Nuovo contatto",
|
||||
"no_category": "Senza categoria",
|
||||
"rename_category": "Rinomina categoria",
|
||||
"category_name_label": "Nome della categoria",
|
||||
"category_renamed": "Categoria rinominata",
|
||||
"category_rename_failed": "Impossibile rinominare la categoria",
|
||||
"category_added": "Contatto aggiunto a {name}",
|
||||
"category_added_plural": "{count} contatti aggiunti a {name}",
|
||||
"empty_state": "Nessun contatto",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "Contatto spostato in {name}",
|
||||
"moved_plural": "{count} contatti spostati in {name}",
|
||||
"move_failed": "Impossibile spostare il contatto",
|
||||
"address_book": "Rubrica"
|
||||
"address_book": "Rubrica",
|
||||
"rename": "Rinomina rubrica",
|
||||
"name_label": "Nome della rubrica",
|
||||
"renamed": "Rubrica rinominata",
|
||||
"rename_failed": "Impossibile rinominare la rubrica",
|
||||
"default": "Predefinita",
|
||||
"manage": "Gestisci rubriche"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Indirizzi email",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||
"manage_title": "アドレス帳",
|
||||
"manage_description": "アドレス帳の名前を変更",
|
||||
"no_address_books": "アドレス帳が見つかりません",
|
||||
"categories_title": "カテゴリ",
|
||||
"categories_description": "連絡先カテゴリの名前を変更",
|
||||
"no_categories": "カテゴリが見つかりません"
|
||||
},
|
||||
"filters": {
|
||||
"title": "メールフィルター",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "連絡先を検索...",
|
||||
"create_new": "新しい連絡先",
|
||||
"no_category": "カテゴリなし",
|
||||
"rename_category": "カテゴリの名前を変更",
|
||||
"category_name_label": "カテゴリ名",
|
||||
"category_renamed": "カテゴリの名前を変更しました",
|
||||
"category_rename_failed": "カテゴリの名前変更に失敗しました",
|
||||
"category_added": "{name} に連絡先を追加しました",
|
||||
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
|
||||
"empty_state": "連絡先がありません",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "連絡先を {name} に移動しました",
|
||||
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
||||
"move_failed": "連絡先の移動に失敗しました",
|
||||
"address_book": "アドレス帳"
|
||||
"address_book": "アドレス帳",
|
||||
"rename": "アドレス帳の名前を変更",
|
||||
"name_label": "アドレス帳名",
|
||||
"renamed": "アドレス帳の名前を変更しました",
|
||||
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
||||
"default": "デフォルト",
|
||||
"manage": "アドレス帳を管理"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "연락처 가져오기",
|
||||
"import_description": "vCard(.vcf) 파일에서 연락처를 가져와요",
|
||||
"export_label": "연락처 내보내기",
|
||||
"export_description": "모든 연락처를 vCard(.vcf) 파일로 저장해요"
|
||||
"export_description": "모든 연락처를 vCard(.vcf) 파일로 저장해요",
|
||||
"manage_title": "주소록",
|
||||
"manage_description": "주소록 이름 변경",
|
||||
"no_address_books": "주소록을 찾을 수 없음",
|
||||
"categories_title": "카테고리",
|
||||
"categories_description": "연락처 카테고리 이름 변경",
|
||||
"no_categories": "카테고리를 찾을 수 없음"
|
||||
},
|
||||
"filters": {
|
||||
"title": "이메일 필터",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "연락처 검색...",
|
||||
"create_new": "새 연락처",
|
||||
"no_category": "카테고리 없음",
|
||||
"rename_category": "카테고리 이름 변경",
|
||||
"category_name_label": "카테고리 이름",
|
||||
"category_renamed": "카테고리 이름이 변경되었습니다",
|
||||
"category_rename_failed": "카테고리 이름 변경 실패",
|
||||
"category_added": "{name}에 연락처가 추가되었어요",
|
||||
"category_added_plural": "{name}에 {count}개의 연락처가 추가되었어요",
|
||||
"empty_state": "아직 연락처가 없어요",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "연락처가 {name}(으)로 이동되었어요",
|
||||
"moved_plural": "{count}개의 연락처가 {name}(으)로 이동되었어요",
|
||||
"move_failed": "연락처를 이동하지 못했어요",
|
||||
"address_book": "주소록"
|
||||
"address_book": "주소록",
|
||||
"rename": "주소록 이름 변경",
|
||||
"name_label": "주소록 이름",
|
||||
"renamed": "주소록 이름이 변경되었습니다",
|
||||
"rename_failed": "주소록 이름 변경 실패",
|
||||
"default": "기본",
|
||||
"manage": "주소록 관리"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "이메일",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||
"manage_title": "Adresboeken",
|
||||
"manage_description": "Hernoem uw adresboeken",
|
||||
"no_address_books": "Geen adresboeken gevonden",
|
||||
"categories_title": "Categorieën",
|
||||
"categories_description": "Contactcategorieën hernoemen",
|
||||
"no_categories": "Geen categorieën gevonden"
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-mailfilters",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "Contacten zoeken...",
|
||||
"create_new": "Nieuw contact",
|
||||
"no_category": "Geen categorie",
|
||||
"rename_category": "Categorie hernoemen",
|
||||
"category_name_label": "Categorienaam",
|
||||
"category_renamed": "Categorie hernoemd",
|
||||
"category_rename_failed": "Categorie hernoemen mislukt",
|
||||
"category_added": "Contact toegevoegd aan {name}",
|
||||
"category_added_plural": "{count} contacten toegevoegd aan {name}",
|
||||
"empty_state": "Geen contacten",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "Contact verplaatst naar {name}",
|
||||
"moved_plural": "{count} contacten verplaatst naar {name}",
|
||||
"move_failed": "Verplaatsen van contact mislukt",
|
||||
"address_book": "Adresboek"
|
||||
"address_book": "Adresboek",
|
||||
"rename": "Adresboek hernoemen",
|
||||
"name_label": "Naam van adresboek",
|
||||
"renamed": "Adresboek hernoemd",
|
||||
"rename_failed": "Adresboek hernoemen mislukt",
|
||||
"default": "Standaard",
|
||||
"manage": "Adresboeken beheren"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-mailadressen",
|
||||
|
||||
+18
-2
@@ -1232,7 +1232,13 @@
|
||||
"import_label": "Importuj kontakty",
|
||||
"import_description": "Importuj kontakty z pliku vCard (.vcf)",
|
||||
"export_label": "Eksportuj kontakty",
|
||||
"export_description": "Eksportuj wszystkie kontakty jako plik vCard (.vcf)"
|
||||
"export_description": "Eksportuj wszystkie kontakty jako plik vCard (.vcf)",
|
||||
"manage_title": "Książki adresowe",
|
||||
"manage_description": "Zmień nazwy swoich książek adresowych",
|
||||
"no_address_books": "Nie znaleziono książek adresowych",
|
||||
"categories_title": "Kategorie",
|
||||
"categories_description": "Zmień nazwy kategorii kontaktów",
|
||||
"no_categories": "Nie znaleziono kategorii"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtry wiadomości e-mail",
|
||||
@@ -1637,6 +1643,10 @@
|
||||
"search_placeholder": "Szukaj kontaktów...",
|
||||
"create_new": "Nowy kontakt",
|
||||
"no_category": "Brak kategorii",
|
||||
"rename_category": "Zmień nazwę kategorii",
|
||||
"category_name_label": "Nazwa kategorii",
|
||||
"category_renamed": "Zmieniono nazwę kategorii",
|
||||
"category_rename_failed": "Nie udało się zmienić nazwy kategorii",
|
||||
"category_added": "Kontakt dodany do {name}",
|
||||
"category_added_plural": "{count} kontaktów dodano do {name}",
|
||||
"empty_state": "Brak kontaktów",
|
||||
@@ -1663,7 +1673,13 @@
|
||||
"moved": "Kontakt przeniesiono do {name}",
|
||||
"moved_plural": "{count} kontaktów przeniesiono do {name}",
|
||||
"move_failed": "Nie udało się przenieść kontaktu",
|
||||
"address_book": "Książka adresowa"
|
||||
"address_book": "Książka adresowa",
|
||||
"rename": "Zmień nazwę książki adresowej",
|
||||
"name_label": "Nazwa książki adresowej",
|
||||
"renamed": "Zmieniono nazwę książki adresowej",
|
||||
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
|
||||
"default": "Domyślna",
|
||||
"manage": "Zarządzaj książkami adresowymi"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresy e-mail",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file"
|
||||
"export_description": "Export all contacts as a vCard (.vcf) file",
|
||||
"manage_title": "Catálogos de endereços",
|
||||
"manage_description": "Renomeie seus catálogos de endereços",
|
||||
"no_address_books": "Nenhum catálogo de endereços encontrado",
|
||||
"categories_title": "Categorias",
|
||||
"categories_description": "Renomear categorias de contatos",
|
||||
"no_categories": "Nenhuma categoria encontrada"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de e-mail",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "Pesquisar contatos...",
|
||||
"create_new": "Novo contato",
|
||||
"no_category": "Sem categoria",
|
||||
"rename_category": "Renomear categoria",
|
||||
"category_name_label": "Nome da categoria",
|
||||
"category_renamed": "Categoria renomeada",
|
||||
"category_rename_failed": "Falha ao renomear a categoria",
|
||||
"category_added": "Contato adicionado a {name}",
|
||||
"category_added_plural": "{count} contatos adicionados a {name}",
|
||||
"empty_state": "Nenhum contato",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "Contato movido para {name}",
|
||||
"moved_plural": "{count} contatos movidos para {name}",
|
||||
"move_failed": "Falha ao mover o contato",
|
||||
"address_book": "Catálogo de endereços"
|
||||
"address_book": "Catálogo de endereços",
|
||||
"rename": "Renomear catálogo de endereços",
|
||||
"name_label": "Nome do catálogo de endereços",
|
||||
"renamed": "Catálogo de endereços renomeado",
|
||||
"rename_failed": "Falha ao renomear o catálogo de endereços",
|
||||
"default": "Padrão",
|
||||
"manage": "Gerenciar catálogos de endereços"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Endereços de e-mail",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "Импорт контактов",
|
||||
"import_description": "Импортировать контакты из файла vCard (.vcf)",
|
||||
"export_label": "Экспорт контактов",
|
||||
"export_description": "Экспортировать все контакты в виде файла vCard (.vcf)"
|
||||
"export_description": "Экспортировать все контакты в виде файла vCard (.vcf)",
|
||||
"manage_title": "Адресные книги",
|
||||
"manage_description": "Переименуйте ваши адресные книги",
|
||||
"no_address_books": "Адресные книги не найдены",
|
||||
"categories_title": "Категории",
|
||||
"categories_description": "Переименование категорий контактов",
|
||||
"no_categories": "Категории не найдены"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фильтры почты",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "Поиск контактов...",
|
||||
"create_new": "Новый контакт",
|
||||
"no_category": "Без категории",
|
||||
"rename_category": "Переименовать категорию",
|
||||
"category_name_label": "Название категории",
|
||||
"category_renamed": "Категория переименована",
|
||||
"category_rename_failed": "Не удалось переименовать категорию",
|
||||
"category_added": "Контакт добавлен в {name}",
|
||||
"category_added_plural": "{count} контактов добавлено в {name}",
|
||||
"empty_state": "Контактов пока нет",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "Контакт перемещён в {name}",
|
||||
"moved_plural": "{count} контактов перемещено в {name}",
|
||||
"move_failed": "Не удалось переместить контакт",
|
||||
"address_book": "Адресная книга"
|
||||
"address_book": "Адресная книга",
|
||||
"rename": "Переименовать адресную книгу",
|
||||
"name_label": "Название адресной книги",
|
||||
"renamed": "Адресная книга переименована",
|
||||
"rename_failed": "Не удалось переименовать адресную книгу",
|
||||
"default": "По умолчанию",
|
||||
"manage": "Управление адресными книгами"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреса электронной почты",
|
||||
|
||||
+18
-2
@@ -1230,7 +1230,13 @@
|
||||
"import_label": "导入联系人",
|
||||
"import_description": "从 vCard (.vcf) 文件导入联系人",
|
||||
"export_label": "导出联系人",
|
||||
"export_description": "将所有联系人导出为 vCard (.vcf) 文件"
|
||||
"export_description": "将所有联系人导出为 vCard (.vcf) 文件",
|
||||
"manage_title": "地址簿",
|
||||
"manage_description": "重命名您的地址簿",
|
||||
"no_address_books": "未找到地址簿",
|
||||
"categories_title": "类别",
|
||||
"categories_description": "重命名联系人类别",
|
||||
"no_categories": "未找到类别"
|
||||
},
|
||||
"filters": {
|
||||
"title": "邮件过滤器",
|
||||
@@ -1635,6 +1641,10 @@
|
||||
"search_placeholder": "搜索联系人...",
|
||||
"create_new": "新联系人",
|
||||
"no_category": "没有类别",
|
||||
"rename_category": "重命名类别",
|
||||
"category_name_label": "类别名称",
|
||||
"category_renamed": "类别已重命名",
|
||||
"category_rename_failed": "重命名类别失败",
|
||||
"category_added": "联系人已添加至 {name}",
|
||||
"category_added_plural": "{count} 联系人已添加到 {name}",
|
||||
"empty_state": "还没有联系人",
|
||||
@@ -1661,7 +1671,13 @@
|
||||
"moved": "联系人已移至 {name}",
|
||||
"moved_plural": "{count} 联系人已移至 {name}",
|
||||
"move_failed": "无法移动联系人",
|
||||
"address_book": "地址簿"
|
||||
"address_book": "地址簿",
|
||||
"rename": "重命名地址簿",
|
||||
"name_label": "地址簿名称",
|
||||
"renamed": "地址簿已重命名",
|
||||
"rename_failed": "重命名地址簿失败",
|
||||
"default": "默认",
|
||||
"manage": "管理地址簿"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "邮箱地址",
|
||||
|
||||
@@ -82,6 +82,8 @@ interface ContactStore {
|
||||
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
|
||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
|
||||
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||
|
||||
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||
}
|
||||
@@ -605,6 +607,57 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
renameAddressBook: async (client, addressBook, newName) => {
|
||||
set({ error: null });
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const originalId = addressBook.originalId || addressBook.id;
|
||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||
await client.updateAddressBook(originalId, { name: trimmed }, accountId);
|
||||
set((state) => ({
|
||||
addressBooks: state.addressBooks.map(b =>
|
||||
b.id === addressBook.id ? { ...b, name: trimmed } : b
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to rename address book';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameKeyword: async (client, oldKeyword, newKeyword) => {
|
||||
set({ error: null });
|
||||
const oldKw = oldKeyword.trim();
|
||||
const newKw = newKeyword.trim();
|
||||
if (!oldKw || !newKw || oldKw === newKw) return;
|
||||
|
||||
const { contacts, supportsSync } = get();
|
||||
const affected = contacts.filter(c => c.keywords?.[oldKw]);
|
||||
|
||||
for (const contact of affected) {
|
||||
const { [oldKw]: _old, ...rest } = contact.keywords || {};
|
||||
const updatedKeywords: Record<string, boolean> = { ...rest, [newKw]: true };
|
||||
try {
|
||||
if (supportsSync && client) {
|
||||
const originalId = contact.originalId || contact.id;
|
||||
const accountId = contact.isShared ? contact.accountId : undefined;
|
||||
await client.updateContact(originalId, { keywords: updatedKeywords }, accountId);
|
||||
}
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === contact.id ? { ...c, keywords: updatedKeywords } : c
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to rename category';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
importContacts: async (client, contacts) => {
|
||||
const { supportsSync } = get();
|
||||
let imported = 0;
|
||||
|
||||
Reference in New Issue
Block a user