fix: reach address book, category and group actions by ⋮, long-press and keyboard, move contacts with Move to…, and delete categories

This commit is contained in:
Linus Rath
2026-10-28 03:59:25 +01:00
parent c87baa8408
commit 754dbf56dc
39 changed files with 741 additions and 142 deletions
@@ -193,4 +193,17 @@ describe('ContactList', () => {
expect(onComposeContact).toHaveBeenCalledWith(alice);
expect(window.location.href).toBe(href);
});
it("moves a contact to another address book from its right-click menu, and the selection from the bulk bar", () => {
const onMoveContacts = vi.fn();
const { unmount } = render(<ContactList {...defaultProps} onMoveContacts={onMoveContacts} />);
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('address_books.move_to'));
expect(onMoveContacts).toHaveBeenCalledWith(['1']);
unmount();
render(<ContactList {...defaultProps} onMoveContacts={onMoveContacts} selectedContactIds={new Set(['1', '2'])} />);
fireEvent.click(screen.getByRole('button', { name: /address_books.move_to/ }));
expect(onMoveContacts).toHaveBeenLastCalledWith(['1', '2']);
});
});
@@ -28,6 +28,7 @@ vi.mock('@/hooks/use-sidebar-apps', () => ({
import { ContactsApp } from '../contacts-app';
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
import { useToastStore } from '@/stores/toast-store';
export const alice: ContactCard = {
id: 'c-alice',
@@ -151,4 +152,19 @@ describe('ContactsApp', () => {
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'import.title' })).toBeNull();
});
it('deletes a category without asking and brings it back on Undo', async () => {
const tagged = { ...alice, keywords: { Friends: true } };
renderContacts([tagged, bob]);
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
fireEvent.click(within(drawer()).getByRole('button', { name: 'row_actions' }));
fireEvent.click(screen.getByText('delete_category'));
await waitFor(() => expect(useContactStore.getState().contacts.find((c) => c.id === 'c-alice')?.keywords?.Friends).toBeUndefined());
expect(screen.queryByRole('alertdialog')).toBeNull();
const toastState = useToastStore.getState().toasts.at(-1)!;
expect(toastState.title).toBe('category_deleted');
await act(async () => { toastState.action!.onClick(); });
await waitFor(() => expect(useContactStore.getState().contacts.find((c) => c.id === 'c-alice')?.keywords?.Friends).toBe(true));
});
});
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { act, render, screen, within } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactsSidebar } from '../contacts-sidebar';
@@ -111,3 +111,55 @@ describe('ContactsSidebar - set default address book', () => {
expect(screen.queryByText('address_books.set_default')).not.toBeInTheDocument();
});
});
describe('ContactsSidebar — row menus without a right-click', () => {
const carol = {
id: 'c1',
addressBookIds: {},
name: { full: 'Carol' },
keywords: { Friends: true },
} as unknown as ContactCard;
function renderRows(onDeleteKeyword = vi.fn(), onRenameKeyword = vi.fn()) {
render(
<ContactsSidebar
groups={[group]}
individuals={[carol]}
addressBooks={[]}
activeCategory="all"
onSelectCategory={vi.fn()}
onCreateGroup={vi.fn()}
onCreateContact={vi.fn()}
onEditGroup={vi.fn()}
onDeleteGroup={vi.fn()}
onRenameKeyword={onRenameKeyword}
onDeleteKeyword={onDeleteKeyword}
/>,
);
return { onDeleteKeyword, onRenameKeyword };
}
it('opens a category menu from its ⋮, with Delete category', () => {
const { onDeleteKeyword } = renderRows();
const row = screen.getByText('Friends').closest('div')!;
fireEvent.click(within(row).getByRole('button', { name: 'row_actions' }));
expect(screen.getByText('rename_category')).toBeInTheDocument();
fireEvent.click(screen.getByText('delete_category'));
expect(onDeleteKeyword).toHaveBeenCalledWith('Friends');
});
it('opens a group menu on long-press', () => {
vi.useFakeTimers();
try {
renderRows();
const row = screen.getByText('Team').closest('div')!;
const touchStart = new Event('touchstart', { bubbles: true });
Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] });
act(() => { row.dispatchEvent(touchStart); });
act(() => { vi.advanceTimersByTime(600); });
expect(screen.getByText('groups.edit')).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Check } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
interface AddressBookPickerDialogProps {
isOpen: boolean;
onClose: () => void;
/** The books contacts can be moved to (writable ones). */
addressBooks: AddressBook[];
/** The book the contacts are in now, when they are all in one. */
currentId?: string | null;
count: number;
onPick: (book: AddressBook) => void;
}
/** "Move to…" for contacts: pick the address book they go to. */
export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, currentId, count, onPick }: AddressBookPickerDialogProps) {
const t = useTranslations("contacts");
const [selected, setSelected] = useState<string | null>(null);
const choice = addressBooks.find((b) => b.id === selected && b.id !== currentId) ?? null;
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={t("address_books.move_title")}
description={t("bulk.adding_contacts", { count })}
onSubmit={(e) => {
e.preventDefault();
if (!choice) return;
onPick(choice);
onClose();
}}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>{t("form.cancel")}</Button>
<Button type="submit" disabled={!choice}>{t("address_books.move_here")}</Button>
</>
}
>
<div role="radiogroup" aria-label={t("address_books.title")} className="mt-4 max-h-80 overflow-y-auto rounded-md border border-border py-1">
{addressBooks.map((book) => {
const isCurrent = book.id === currentId;
const isSelected = selected === book.id;
return (
<button
key={book.id}
type="button"
role="radio"
aria-checked={isSelected}
disabled={isCurrent}
onClick={() => setSelected(book.id)}
onDoubleClick={() => { if (!isCurrent) { onPick(book); onClose(); } }}
className={cn(
"w-full flex items-center gap-2 px-3 h-9 text-sm text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isSelected ? "bg-selection" : "hover:bg-muted",
isCurrent && "text-muted-foreground",
)}
>
<Book className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="truncate flex-1" title={book.name}>{book.name}</span>
{book.isShared && book.accountName && (
<span className="text-xs text-muted-foreground truncate max-w-[40%]" title={book.accountName}>{book.accountName}</span>
)}
{isSelected && <Check className="w-4 h-4 shrink-0 text-primary" />}
</button>
);
})}
</div>
</Dialog>
);
}
@@ -18,6 +18,7 @@ import {
Trash2,
Copy,
Printer,
FolderInput,
} from "@/components/icons";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store";
@@ -50,6 +51,9 @@ interface ContactContextMenuProps {
/** Writes to the contact in the app's composer. */
onCompose?: () => void;
onDuplicate?: () => void;
/** Move to another address book; undefined where there is none to move to. */
onMoveTo?: () => void;
onBatchMoveTo?: () => void;
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
@@ -69,6 +73,8 @@ export function ContactContextMenu({
onAddToGroup,
onCompose,
onDuplicate,
onMoveTo,
onBatchMoveTo,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
@@ -121,6 +127,9 @@ export function ContactContextMenu({
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
{onBatchMoveTo && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onBatchMoveTo)} />
)}
<ContextMenuItem
icon={Download}
label={t("bulk.export")}
@@ -185,6 +194,9 @@ export function ContactContextMenu({
onClick={handle(onDuplicate)}
/>
)}
{onMoveTo && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
)}
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
+7 -2
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "@/components/icons";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer, FolderInput } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
import { MailtoLink } from "@/components/ui/mailto-link";
import { Button } from "@/components/ui/button";
@@ -36,6 +36,8 @@ interface ContactDetailProps {
onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
/** Move to another address book; undefined where there is none to move to. */
onMoveTo?: () => void;
/** Compose an email to this contact in the app (no OS mailto handoff). */
onCompose?: () => void;
isMobile?: boolean;
@@ -138,7 +140,7 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key);
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key);
@@ -187,6 +189,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
if (onDuplicate) {
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
}
if (onMoveTo) {
moreItems.push({ icon: FolderInput, label: t("address_books.move_to"), onClick: onMoveTo });
}
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport, disabled: exporting });
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
moreItems.push({ separator: true });
+12 -1
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2 } from "@/components/icons";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2, FolderInput } from "@/components/icons";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
@@ -116,6 +116,8 @@ interface ContactListProps {
onAddContactToGroup: (id: string) => void;
/** Opens the app's composer to the contact. */
onComposeContact?: (contact: ContactCard) => void;
/** Move contacts to another address book; undefined where there is none. */
onMoveContacts?: (ids: string[]) => void;
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
onMenuClick?: () => void;
/** The first fetch is still running: show placeholders, not "No contacts yet". */
@@ -142,6 +144,7 @@ export function ContactList({
onDeleteContact,
onAddContactToGroup,
onComposeContact,
onMoveContacts,
onMenuClick,
isLoading = false,
}: ContactListProps) {
@@ -515,6 +518,12 @@ export function ContactList({
<Users className="w-3.5 h-3.5 me-1" />
{t("bulk.add_to_group")}
</Button>
{onMoveContacts && (
<Button variant="ghost" size="sm" onClick={() => onMoveContacts([...selectedContactIds])} className="h-7 text-xs">
<FolderInput className="w-3.5 h-3.5 me-1" />
{t("address_books.move_to")}
</Button>
)}
<Button variant="ghost" size="sm" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} className="h-7 text-xs">
{exporting
? <Loader2 className="w-3.5 h-3.5 me-1 animate-spin" />
@@ -654,6 +663,8 @@ export function ContactList({
onDelete={() => onDeleteContact(contextMenu.data!)}
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined}
onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined}
onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined}
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
+56
View File
@@ -15,6 +15,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 { AddressBookPickerDialog } from "@/components/contacts/address-book-picker-dialog";
import { DirectoryBrowser, directoryKey } from "@/components/contacts/directory-browser";
import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-detail";
import { PromptDialog } from "@/components/ui/prompt-dialog";
@@ -116,6 +117,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
shareAddressBook,
setDefaultAddressBook,
renameKeyword,
removeKeyword,
addKeyword,
importContacts,
} = useContactStore();
@@ -129,6 +132,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
const [returnToEmail, setReturnToEmail] = useState(false);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
// "Move to…": the contacts waiting for an address book to be picked.
const [movingContactIds, setMovingContactIds] = useState<string[] | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
// Who a new group starts with: the selection when it came from "Add to group".
const [newGroupMemberIds, setNewGroupMemberIds] = useState<string[]>([]);
@@ -415,6 +420,44 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}
}, [client, moveContactToAddressBook, t]);
// Address books contacts can be moved to; "Move to…" needs a second one.
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
const canMoveContacts = !!client && supportsSync && writableBooks.length > 1;
const handleMoveContacts = useCallback((ids: string[]) => {
if (ids.length > 0) setMovingContactIds(ids);
}, []);
// The book the contacts are in, when they share one, so the picker can leave it out.
const movingFromBookId = useMemo(() => {
if (!movingContactIds) return null;
const books = new Set(movingContactIds.flatMap((id) => {
const c = contacts.find((x) => x.id === id);
return c?.addressBookIds ? Object.keys(c.addressBookIds).filter((k) => c.addressBookIds[k]) : [];
}));
return books.size === 1 ? [...books][0] : null;
}, [movingContactIds, contacts]);
// Deleting a category only takes a label off contacts: no question, an Undo (R3).
const handleDeleteKeyword = useCallback(async (keyword: string) => {
const jmapClient = supportsSync && client ? client : null;
try {
const ids = await removeKeyword(jmapClient, keyword);
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword) {
setActiveCategory("all");
}
toast.success(t("category_deleted", { name: keyword }), {
action: {
label: t("undo"),
onClick: () => {
void addKeyword(jmapClient, keyword, ids).catch(() => toast.error(t("toast.error_update")));
},
},
});
} catch (error) {
console.error("Failed to delete category:", error);
toast.error(t("category_delete_failed"));
}
}, [supportsSync, client, removeKeyword, addKeyword, activeCategory, t]);
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
if (!client && supportsSync) return;
try {
@@ -1002,6 +1045,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
? () => void handleDuplicateContact(selectedContact)
: undefined
}
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
isMobile={isMobile}
/>
);
@@ -1146,6 +1190,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}
}) : undefined}
onRenameKeyword={fromDrawer((kw: string) => setRenamingKeyword(kw))}
onDeleteKeyword={fromDrawer((kw: string) => void handleDeleteKeyword(kw))}
directoryCount={showDirectory ? directoryPrincipals.length : undefined}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/>
@@ -1223,6 +1268,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onDeleteContact={handleDeleteContact}
onAddContactToGroup={guard(handleAddContactToGroup)}
onComposeContact={handleComposeContact}
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
isLoading={supportsSync && !contactsLoaded}
/>
@@ -1335,6 +1381,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
})();
}}
/>
<AddressBookPickerDialog
isOpen={movingContactIds !== null}
onClose={() => setMovingContactIds(null)}
addressBooks={writableBooks}
currentId={movingFromBookId}
count={movingContactIds?.length ?? 0}
onPick={(book) => {
if (movingContactIds) void handleDropContacts(movingContactIds, book);
}}
/>
{showImportDialog && (
<ContactImportDialog
existingContacts={contacts}
+171 -93
View File
@@ -2,11 +2,13 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, Building2, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "@/components/icons";
import { BookUser, Building2, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star, MoreVertical } from "@/components/icons";
import { useRouter } from "@/i18n/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { isContextMenuKey, useContextMenu } from "@/hooks/use-context-menu";
import { useLongPress } from "@/hooks/use-long-press";
import { IconButton } from "@/components/ui/icon-button";
import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
@@ -35,6 +37,8 @@ interface ContactsSidebarProps {
onCreateContactInBook?: (addressBook: AddressBook) => void;
onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
/** Take the category off every contact. */
onDeleteKeyword?: (keyword: string) => void;
/** Entries in the company directory; the Directory entry shows only when set. */
directoryCount?: number;
className?: string;
@@ -108,16 +112,24 @@ export function ContactsSidebar({
onCreateContactInBook,
onDeleteAddressBook,
onRenameKeyword,
onDeleteKeyword,
directoryCount,
className,
multiAccountMode,
}: 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: groupContextMenu, openContextMenu: openGroupContextMenu, openContextMenuFrom: openGroupMenuFrom, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, openContextMenuFrom: openBookMenuFrom, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const hasBookMenu = !!(onRenameAddressBook || onShareAddressBook || onSetDefaultAddressBook || onCreateContactInBook || onDeleteAddressBook);
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, openContextMenuFrom: openKeywordMenuFrom, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const hasKeywordMenu = !!(onRenameKeyword || onDeleteKeyword);
// Every row with a menu opens it by right-click, its ⋮, long-press and the menu key.
const bookMenu = (book: AddressBook) => hasBookMenu ? {
onContextMenu: (e: React.MouseEvent) => openBookContextMenu(e, book),
onOpenMenu: (anchor: HTMLElement) => openBookMenuFrom(anchor, book, "end"),
menuLabel: t("row_actions", { name: book.name }),
} : {};
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
const [showMenu, setShowMenu] = useState(false);
@@ -413,7 +425,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
{...bookMenu(book)}
/>
))}
</div>
@@ -432,7 +444,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
{...bookMenu(book)}
/>
))}
</div>
@@ -479,7 +491,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
{...bookMenu(book)}
/>
))}
</div>
@@ -507,25 +519,19 @@ export function ContactsSidebar({
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
const memberCount = memberCountByGroup[group.id] || 0;
const groupName = getContactDisplayName(group);
return (
<button
<SidebarRow
key={group.id}
onClick={() => onSelectCategory({ groupId: group.id })}
isActive={isActive}
icon={<Users className="w-4 h-4 flex-shrink-0" />}
label={groupName}
count={memberCount}
onSelect={() => onSelectCategory({ groupId: group.id })}
onContextMenu={(e) => openGroupContextMenu(e, group)}
className={cn(
"w-full flex items-center gap-2 ps-5 pe-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' }}
>
<Users className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{getContactDisplayName(group)}</span>
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{memberCount}
</span>
</button>
onOpenMenu={(anchor) => openGroupMenuFrom(anchor, group, "end")}
menuLabel={t("row_actions", { name: groupName })}
/>
);
})}
</div>
@@ -576,7 +582,9 @@ export function ContactsSidebar({
isActive={isActive}
onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory}
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
onContextMenu={hasKeywordMenu ? (e) => openKeywordContextMenu(e, keyword) : undefined}
onOpenMenu={hasKeywordMenu ? (anchor) => openKeywordMenuFrom(anchor, keyword, "end") : undefined}
menuLabel={t("row_actions", { name: keyword })}
/>
);
})}
@@ -623,7 +631,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
{...bookMenu(book)}
/>
))}
</div>
@@ -705,22 +713,37 @@ export function ContactsSidebar({
})()}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
{keywordContextMenu.data && hasKeywordMenu && (
<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);
}}
/>
{onRenameKeyword && (
<ContextMenuItem
icon={Pencil}
label={t("rename_category")}
onClick={() => {
const kw = keywordContextMenu.data!;
closeKeywordContextMenu();
onRenameKeyword(kw);
}}
/>
)}
{onRenameKeyword && onDeleteKeyword && <ContextMenuSeparator />}
{onDeleteKeyword && (
<ContextMenuItem
icon={Trash2}
label={t("delete_category")}
destructive
onClick={() => {
const kw = keywordContextMenu.data!;
closeKeywordContextMenu();
onDeleteKeyword(kw);
}}
/>
)}
</ContextMenu>
)}
@@ -771,6 +794,83 @@ export function ContactsSidebar({
);
}
const ROW_STYLE = { paddingBlock: "var(--density-sidebar-py, 4px)", minHeight: "32px" } as const;
interface RowMenuProps {
/** Right-click: the menu at the pointer. */
onContextMenu?: (e: React.MouseEvent) => void;
/** The ⋮, long-press and the menu key: the menu under the element. */
onOpenMenu?: (anchor: HTMLElement) => void;
menuLabel?: string;
}
/**
* A sidebar row: the row itself selects, a ⋮ at its end (shown on hover and
* focus, always on touch) opens its menu, as do right-click, long-press and
* Shift+F10 or the Menu key (R6, R7).
*/
function SidebarRow({
isActive, isDragOver, icon, label, count, extra, onSelect, onContextMenu, onOpenMenu, menuLabel, dropProps, rowProps,
}: RowMenuProps & {
isActive: boolean;
isDragOver?: boolean;
icon: React.ReactNode;
label: string;
count: number;
extra?: React.ReactNode;
onSelect: () => void;
dropProps?: React.HTMLAttributes<HTMLDivElement>;
rowProps?: Record<string, string>;
}) {
const rowRef = useRef<HTMLDivElement>(null);
const longPress = useLongPress(() => {
if (rowRef.current && onOpenMenu) onOpenMenu(rowRef.current);
}, !!onOpenMenu);
return (
<div
ref={rowRef}
{...rowProps}
{...dropProps}
{...(onOpenMenu ? longPress : {})}
onContextMenu={onContextMenu}
className={cn(
"group w-full flex items-center ps-5 pe-1 text-sm transition-colors select-none",
isActive ? "bg-accent text-accent-foreground font-medium" : "text-foreground/80 hover:bg-muted",
isDragOver && "bg-primary/20 ring-2 ring-primary/50",
)}
style={ROW_STYLE}
>
<button
type="button"
onClick={onSelect}
onKeyDown={(e) => {
if (onOpenMenu && isContextMenuKey(e)) {
e.preventDefault();
onOpenMenu(e.currentTarget);
}
}}
aria-current={isActive ? "true" : undefined}
className="flex flex-1 min-w-0 items-center gap-2 self-stretch text-start rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{icon}
<span className="truncate" title={label}>{label}</span>
{extra}
<span className={cn("pe-2 text-xs text-muted-foreground tabular-nums", !extra && "ms-auto")}>{count}</span>
</button>
{onOpenMenu && menuLabel && (
<IconButton
label={menuLabel}
aria-haspopup="menu"
onClick={(e) => onOpenMenu(e.currentTarget)}
className="shrink-0 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
>
<MoreVertical />
</IconButton>
)}
</div>
);
}
function CategoryItem({
keyword,
count,
@@ -778,17 +878,18 @@ function CategoryItem({
onSelect,
onDropContacts,
onContextMenu,
}: {
onOpenMenu,
menuLabel,
}: RowMenuProps & {
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);
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
@@ -799,7 +900,7 @@ function CategoryItem({
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const data = e.dataTransfer.getData("application/x-contact-ids");
@@ -815,27 +916,18 @@ function CategoryItem({
}, [keyword, onDropContacts]);
return (
<button
onClick={onSelect}
<SidebarRow
isActive={isActive}
isDragOver={isDragOver}
icon={<Tag className="w-3.5 h-3.5 flex-shrink-0" />}
label={keyword}
count={count}
onSelect={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
)}
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="ms-auto text-xs text-muted-foreground tabular-nums">
{count}
</span>
</button>
onOpenMenu={onOpenMenu}
menuLabel={menuLabel}
dropProps={{ onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop }}
/>
);
}
@@ -846,17 +938,18 @@ function AddressBookItem({
onSelect,
onDropContacts,
onContextMenu,
}: {
onOpenMenu,
menuLabel,
}: RowMenuProps & {
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);
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
@@ -867,7 +960,7 @@ function AddressBookItem({
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const data = e.dataTransfer.getData("application/x-contact-ids");
@@ -882,36 +975,21 @@ function AddressBookItem({
}
}, [book, onDropContacts]);
const sharedOut = !book.isShared && Object.keys(book.shareWith || {}).length > 0;
return (
<button
onClick={onSelect}
<SidebarRow
isActive={isActive}
isDragOver={isDragOver}
icon={<Book className="w-4 h-4 flex-shrink-0" />}
label={book.name}
count={contactCount}
extra={sharedOut ? <Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" /> : undefined}
onSelect={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
data-testid="address-book-item"
data-book-name={book.name}
data-account={book.accountName ?? ''}
className={cn(
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<Book className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{book.name}</span>
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" />
)}
<span className={cn(
"text-xs text-muted-foreground tabular-nums",
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
)}>
{contactCount}
</span>
</button>
onOpenMenu={onOpenMenu}
menuLabel={menuLabel}
dropProps={{ onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop }}
rowProps={{ "data-testid": "address-book-item", "data-book-name": book.name, "data-account": book.accountName ?? "" }}
/>
);
}
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "اسم الفئة",
"category_renamed": "تمت إعادة تسمية الفئة",
"category_rename_failed": "فشلت إعادة تسمية الفئة",
"delete_category": "حذف الفئة",
"category_deleted": "حُذفت الفئة \"{name}\"",
"category_delete_failed": "تعذّر حذف الفئة",
"undo": "تراجع",
"category_added": "تمت إضافة جهة الاتصال إلى {name}",
"category_added_plural": "تمت إضافة {count} جهات اتصال إلى {name}",
"empty_state": "لا توجد جهات اتصال بعد",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "العودة إلى جهات الاتصال",
"back_to_email": "العودة إلى البريد",
"open_categories": "فتح الفئات",
"row_actions": "إجراءات {name}",
"tabs": {
"all": "الكل",
"groups": "المجموعات"
@@ -2960,7 +2965,10 @@
"delete": "حذف دفتر العناوين",
"confirm_delete": "حذف \"{name}\"؟ ستتم إزالة جميع جهات الاتصال في دفتر العناوين هذا.",
"deleted": "تم حذف دفتر العناوين",
"delete_failed": "فشل حذف دفتر العناوين"
"delete_failed": "فشل حذف دفتر العناوين",
"move_to": "نقل إلى…",
"move_title": "نقل إلى دفتر عناوين",
"move_here": "نقل إلى هنا"
},
"detail": {
"emails": "عناوين البريد الإلكتروني",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Nom de la categoria",
"category_renamed": "Nom de la categoria canviat",
"category_rename_failed": "No s'ha pogut canviar el nom de la categoria",
"delete_category": "Suprimeix la categoria",
"category_deleted": "S'ha suprimit la categoria «{name}»",
"category_delete_failed": "No s'ha pogut suprimir la categoria",
"undo": "Desfés",
"category_added": "Contacte afegit a {name}",
"category_added_plural": "{count} contactes afegits a {name}",
"empty_state": "Encara no hi ha contactes",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "Torna als contactes",
"back_to_email": "Torna al correu",
"open_categories": "Obre les categories",
"row_actions": "Accions per a {name}",
"tabs": {
"all": "Tots",
"groups": "Grups"
@@ -2960,7 +2965,10 @@
"delete": "Suprimeix la llibreta d'adreces",
"confirm_delete": "Voleu suprimir «{name}»? S'eliminaran tots els contactes d'aquesta llibreta d'adreces.",
"deleted": "Llibreta d'adreces suprimida",
"delete_failed": "No s'ha pogut suprimir la llibreta d'adreces"
"delete_failed": "No s'ha pogut suprimir la llibreta d'adreces",
"move_to": "Mou a…",
"move_title": "Mou a una llibreta d'adreces",
"move_here": "Mou aquí"
},
"detail": {
"emails": "Adreces electròniques",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Název kategorie",
"category_renamed": "Kategorie byla přejmenována",
"category_rename_failed": "Přejmenování kategorie selhalo",
"delete_category": "Smazat kategorii",
"category_deleted": "Kategorie „{name}“ byla smazána",
"category_delete_failed": "Kategorii se nepodařilo smazat",
"undo": "Zpět",
"category_added": "Kontakt přidán do {name}",
"category_added_plural": "{count} kontaktů bylo přidáno do {name}",
"empty_state": "Žádné kontakty",
@@ -2959,7 +2963,10 @@
"delete": "Smazat adresář",
"confirm_delete": "Smazat „{name}\"? Všechny kontakty v tomto adresáři budou odstraněny.",
"deleted": "Adresář smazán",
"delete_failed": "Adresář se nepodařilo smazat"
"delete_failed": "Adresář se nepodařilo smazat",
"move_to": "Přesunout do…",
"move_title": "Přesunout do adresáře",
"move_here": "Přesunout sem"
},
"detail": {
"emails": "E-mailové adresy",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Jméno",
"sort_last_name": "Příjmení"
},
"open_categories": "Otevřít kategorie"
"open_categories": "Otevřít kategorie",
"row_actions": "Akce pro {name}"
},
"calendar": {
"title": "Kalendář",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Kategorinavn",
"category_renamed": "Kategori omdøbt",
"category_rename_failed": "Kunne ikke omdøbe kategori",
"delete_category": "Slet kategori",
"category_deleted": "Kategorien \"{name}\" er slettet",
"category_delete_failed": "Kunne ikke slette kategorien",
"undo": "Fortryd",
"category_added": "Kontakt tilføjet til {name}",
"category_added_plural": "{count} kontakter tilføjet til {name}",
"empty_state": "Ingen kontakter endnu",
@@ -2959,7 +2963,10 @@
"delete": "Slet adressebog",
"confirm_delete": "Slet \"{name}\"? Alle kontakter i denne adressebog fjernes.",
"deleted": "Adressebog slettet",
"delete_failed": "Kunne ikke slette adressebog"
"delete_failed": "Kunne ikke slette adressebog",
"move_to": "Flyt til…",
"move_title": "Flyt til adressebog",
"move_here": "Flyt hertil"
},
"detail": {
"emails": "E-mailadresser",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Fornavn",
"sort_last_name": "Efternavn"
},
"open_categories": "Åbn kategorier"
"open_categories": "Åbn kategorier",
"row_actions": "Handlinger for {name}"
},
"calendar": {
"title": "Kalender",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Kategoriename",
"category_renamed": "Kategorie umbenannt",
"category_rename_failed": "Kategorie konnte nicht umbenannt werden",
"delete_category": "Kategorie löschen",
"category_deleted": "Kategorie „{name}“ gelöscht",
"category_delete_failed": "Kategorie konnte nicht gelöscht werden",
"undo": "Rückgängig",
"category_added": "Kontakt zu {name} hinzugefügt",
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
"empty_state": "Keine Kontakte",
@@ -2959,7 +2963,10 @@
"delete": "Adressbuch löschen",
"confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.",
"deleted": "Adressbuch gelöscht",
"delete_failed": "Adressbuch konnte nicht gelöscht werden"
"delete_failed": "Adressbuch konnte nicht gelöscht werden",
"move_to": "Verschieben nach…",
"move_title": "In Adressbuch verschieben",
"move_here": "Hierher verschieben"
},
"detail": {
"emails": "E-Mail-Adressen",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Vorname",
"sort_last_name": "Nachname"
},
"open_categories": "Kategorien öffnen"
"open_categories": "Kategorien öffnen",
"row_actions": "Aktionen für {name}"
},
"calendar": {
"title": "Kalender",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Category name",
"category_renamed": "Category renamed",
"category_rename_failed": "Failed to rename category",
"delete_category": "Delete category",
"category_deleted": "Category \"{name}\" deleted",
"category_delete_failed": "Couldn't delete the category",
"undo": "Undo",
"category_added": "Contact added to {name}",
"category_added_plural": "{count} contacts added to {name}",
"empty_state": "No contacts yet",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "Back to contacts",
"back_to_email": "Back to email",
"open_categories": "Open categories",
"row_actions": "Actions for {name}",
"tabs": {
"all": "All",
"groups": "Groups"
@@ -2960,7 +2965,10 @@
"delete": "Delete address book",
"confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.",
"deleted": "Address book deleted",
"delete_failed": "Failed to delete address book"
"delete_failed": "Failed to delete address book",
"move_to": "Move to…",
"move_title": "Move to address book",
"move_here": "Move here"
},
"detail": {
"emails": "Email Addresses",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Nombre de la categoría",
"category_renamed": "Categoría renombrada",
"category_rename_failed": "Error al renombrar la categoría",
"delete_category": "Eliminar categoría",
"category_deleted": "Categoría «{name}» eliminada",
"category_delete_failed": "No se ha podido eliminar la categoría",
"undo": "Deshacer",
"category_added": "Contacto añadido a {name}",
"category_added_plural": "{count} contactos añadidos a {name}",
"empty_state": "No hay contactos",
@@ -2959,7 +2963,10 @@
"delete": "Eliminar libreta de direcciones",
"confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.",
"deleted": "Libreta de direcciones eliminada",
"delete_failed": "No se pudo eliminar la libreta de direcciones"
"delete_failed": "No se pudo eliminar la libreta de direcciones",
"move_to": "Mover a…",
"move_title": "Mover a una libreta de direcciones",
"move_here": "Mover aquí"
},
"detail": {
"emails": "Direcciones de correo",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Nombre",
"sort_last_name": "Apellido"
},
"open_categories": "Abrir categorías"
"open_categories": "Abrir categorías",
"row_actions": "Acciones para {name}"
},
"calendar": {
"title": "Calendario",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Kategooria nimi",
"category_renamed": "Kategooria nimetati ümber",
"category_rename_failed": "Kategooria ümbernimetamine ebaõnnestus",
"delete_category": "Kustuta kategooria",
"category_deleted": "Kategooria „{name}“ kustutati",
"category_delete_failed": "Kategooriat ei õnnestunud kustutada",
"undo": "Võta tagasi",
"category_added": "Kontakt lisati kategooriasse {name}",
"category_added_plural": "{count} kontakti lisati kategooriasse {name}",
"empty_state": "Kontakte veel pole",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "Tagasi kontaktide juurde",
"back_to_email": "Tagasi e-posti juurde",
"open_categories": "Ava kategooriad",
"row_actions": "Toimingud: {name}",
"tabs": {
"all": "Kõik",
"groups": "Grupid"
@@ -2960,7 +2965,10 @@
"delete": "Kustuta aadressiraamat",
"confirm_delete": "Kustutada \"{name}\"? Kõik selle aadressiraamatu kontaktid eemaldatakse.",
"deleted": "Aadressiraamat kustutatud",
"delete_failed": "Aadressiraamatu kustutamine ebaõnnestus"
"delete_failed": "Aadressiraamatu kustutamine ebaõnnestus",
"move_to": "Teisalda…",
"move_title": "Teisalda aadressiraamatusse",
"move_here": "Teisalda siia"
},
"detail": {
"emails": "E-posti aadressid",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "نام دسته‌بندی",
"category_renamed": "نام دسته‌بندی تغییر کرد",
"category_rename_failed": "تغییر نام دسته‌بندی ناموفق بود",
"delete_category": "حذف دسته",
"category_deleted": "دسته «{name}» حذف شد",
"category_delete_failed": "دسته حذف نشد",
"undo": "واگرد",
"category_added": "مخاطب به {name} اضافه شد",
"category_added_plural": "{count} مخاطب به {name} اضافه شد",
"empty_state": "هنوز مخاطبی نیست",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "بازگشت به مخاطبین",
"back_to_email": "بازگشت به ایمیل",
"open_categories": "باز کردن دسته‌بندی‌ها",
"row_actions": "کنش‌های {name}",
"tabs": {
"all": "همه",
"groups": "گروه‌ها"
@@ -2960,7 +2965,10 @@
"delete": "حذف دفترچه آدرس",
"confirm_delete": "حذف \"{name}\"؟ همه مخاطبین این دفترچه آدرس حذف خواهند شد.",
"deleted": "دفترچه آدرس حذف شد",
"delete_failed": "حذف دفترچه آدرس ناموفق بود"
"delete_failed": "حذف دفترچه آدرس ناموفق بود",
"move_to": "انتقال به…",
"move_title": "انتقال به دفترچه نشانی",
"move_here": "انتقال به اینجا"
},
"detail": {
"emails": "آدرس‌های ایمیل",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Nom de la catégorie",
"category_renamed": "Catégorie renommée",
"category_rename_failed": "Échec du renommage de la catégorie",
"delete_category": "Supprimer la catégorie",
"category_deleted": "Catégorie « {name} » supprimée",
"category_delete_failed": "Impossible de supprimer la catégorie",
"undo": "Annuler",
"category_added": "Contact ajouté à {name}",
"category_added_plural": "{count} contacts ajoutés à {name}",
"empty_state": "Aucun contact",
@@ -2959,7 +2963,10 @@
"delete": "Supprimer le carnet d'adresses",
"confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.",
"deleted": "Carnet d'adresses supprimé",
"delete_failed": "Échec de la suppression du carnet d'adresses"
"delete_failed": "Échec de la suppression du carnet d'adresses",
"move_to": "Déplacer vers…",
"move_title": "Déplacer vers un carnet d'adresses",
"move_here": "Déplacer ici"
},
"detail": {
"emails": "Adresses e-mail",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Prénom",
"sort_last_name": "Nom de famille"
},
"open_categories": "Ouvrir les catégories"
"open_categories": "Ouvrir les catégories",
"row_actions": "Actions pour {name}"
},
"calendar": {
"title": "Calendrier",
+9 -1
View File
@@ -2829,6 +2829,10 @@
"category_name_label": "שם קטגוריה",
"category_renamed": "שם קטגוריה שונה",
"category_rename_failed": "שינוי שם הקטגוריה נכשל",
"delete_category": "מחק קטגוריה",
"category_deleted": "הקטגוריה \"{name}\" נמחקה",
"category_delete_failed": "לא ניתן היה למחוק את הקטגוריה",
"undo": "בטל",
"category_added": "איש קשר נוסף ל-{name}",
"category_added_plural": "אנשי הקשר{count}נוספו ל-{name}",
"empty_state": "עדיין אין אנשי קשר",
@@ -2874,7 +2878,10 @@
"delete": "מחק ספר כתובות",
"confirm_delete": "מחק \"{name}\"? כל אנשי הקשר בספר כתובות זה יוסרו.",
"deleted": "ספר כתובות נמחק",
"delete_failed": "נכשל במחיקת ספר כתובות"
"delete_failed": "נכשל במחיקת ספר כתובות",
"move_to": "העבר אל…",
"move_title": "העבר לפנקס כתובות",
"move_here": "העבר לכאן"
},
"detail": {
"emails": "כתובות אימייל",
@@ -3122,6 +3129,7 @@
"empty_filtered_hint": "נסה להתאים או להביא סננים",
"back_to_email": "חזור לדוא״ל",
"open_categories": "קטגוריות פתוחות",
"row_actions": "פעולות עבור {name}",
"activity": {
"recent_emails": "דוא״ל אחרונים",
"upcoming_events": "אירועים קרובים",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Kategória neve",
"category_renamed": "Kategória átnevezve",
"category_rename_failed": "Nem sikerült átnevezni a kategóriát",
"delete_category": "Kategória törlése",
"category_deleted": "A(z) „{name}” kategória törölve",
"category_delete_failed": "Nem sikerült törölni a kategóriát",
"undo": "Visszavonás",
"category_added": "Névjegy hozzáadva ide: {name}",
"category_added_plural": "{count} névjegy hozzáadva ide: {name}",
"empty_state": "Még nincsenek névjegyek",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "Vissza a névjegyekhez",
"back_to_email": "Vissza az e-mailekhez",
"open_categories": "Kategóriák megnyitása",
"row_actions": "Műveletek: {name}",
"tabs": {
"all": "Összes",
"groups": "Csoportok"
@@ -2960,7 +2965,10 @@
"delete": "Címjegyzék törlése",
"confirm_delete": "Törlöd a \"{name}\" címjegyzéket? Az összes névjegy eltávolításra kerül.",
"deleted": "Címjegyzék törölve",
"delete_failed": "Nem sikerült törölni a címjegyzéket"
"delete_failed": "Nem sikerült törölni a címjegyzéket",
"move_to": "Áthelyezés ide…",
"move_title": "Áthelyezés címjegyzékbe",
"move_here": "Áthelyezés ide"
},
"detail": {
"emails": "E-mail címek",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Nome della categoria",
"category_renamed": "Categoria rinominata",
"category_rename_failed": "Impossibile rinominare la categoria",
"delete_category": "Elimina categoria",
"category_deleted": "Categoria «{name}» eliminata",
"category_delete_failed": "Impossibile eliminare la categoria",
"undo": "Annulla",
"category_added": "Contatto aggiunto a {name}",
"category_added_plural": "{count} contatti aggiunti a {name}",
"empty_state": "Nessun contatto",
@@ -2959,7 +2963,10 @@
"delete": "Elimina rubrica",
"confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.",
"deleted": "Rubrica eliminata",
"delete_failed": "Impossibile eliminare la rubrica"
"delete_failed": "Impossibile eliminare la rubrica",
"move_to": "Sposta in…",
"move_title": "Sposta in una rubrica",
"move_here": "Sposta qui"
},
"detail": {
"emails": "Indirizzi email",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Nome",
"sort_last_name": "Cognome"
},
"open_categories": "Apri categorie"
"open_categories": "Apri categorie",
"row_actions": "Azioni per {name}"
},
"calendar": {
"title": "Calendario",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "カテゴリ名",
"category_renamed": "カテゴリの名前を変更しました",
"category_rename_failed": "カテゴリの名前変更に失敗しました",
"delete_category": "カテゴリを削除",
"category_deleted": "カテゴリ「{name}」を削除しました",
"category_delete_failed": "カテゴリを削除できませんでした",
"undo": "元に戻す",
"category_added": "{name} に連絡先を追加しました",
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
"empty_state": "連絡先がありません",
@@ -2959,7 +2963,10 @@
"delete": "アドレス帳を削除",
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
"deleted": "アドレス帳を削除しました",
"delete_failed": "アドレス帳の削除に失敗しました"
"delete_failed": "アドレス帳の削除に失敗しました",
"move_to": "移動先…",
"move_title": "アドレス帳に移動",
"move_here": "ここに移動"
},
"detail": {
"emails": "メールアドレス",
@@ -3248,7 +3255,8 @@
"sort_first_name": "名",
"sort_last_name": "姓"
},
"open_categories": "カテゴリを開く"
"open_categories": "カテゴリを開く",
"row_actions": "{name} の操作"
},
"calendar": {
"title": "カレンダー",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "카테고리 이름",
"category_renamed": "카테고리 이름이 변경되었습니다",
"category_rename_failed": "카테고리 이름 변경 실패",
"delete_category": "카테고리 삭제",
"category_deleted": "\"{name}\" 카테고리를 삭제했습니다",
"category_delete_failed": "카테고리를 삭제하지 못했습니다",
"undo": "실행 취소",
"category_added": "{name}에 연락처가 추가되었어요",
"category_added_plural": "{name}에 {count}개의 연락처가 추가되었어요",
"empty_state": "아직 연락처가 없어요",
@@ -2959,7 +2963,10 @@
"delete": "주소록 삭제",
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
"deleted": "주소록이 삭제되었습니다",
"delete_failed": "주소록 삭제에 실패했습니다"
"delete_failed": "주소록 삭제에 실패했습니다",
"move_to": "이동…",
"move_title": "주소록으로 이동",
"move_here": "여기로 이동"
},
"detail": {
"emails": "이메일",
@@ -3248,7 +3255,8 @@
"sort_first_name": "이름",
"sort_last_name": "성"
},
"open_categories": "카테고리 열기"
"open_categories": "카테고리 열기",
"row_actions": "{name} 작업"
},
"calendar": {
"title": "캘린더",
+10 -2
View File
@@ -2955,7 +2955,10 @@
"delete": "Dzēst adrešu grāmatu",
"confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.",
"deleted": "Adrešu grāmata dzēsta",
"delete_failed": "Neizdevās dzēst adrešu grāmatu"
"delete_failed": "Neizdevās dzēst adrešu grāmatu",
"move_to": "Pārvietot uz…",
"move_title": "Pārvietot uz adrešu grāmatu",
"move_here": "Pārvietot šeit"
},
"detail": {
"emails": "E-pasta adreses",
@@ -3214,6 +3217,10 @@
"category_name_label": "Kategorijas nosaukums",
"category_renamed": "Kategorija pārdēvēta",
"category_rename_failed": "Neizdevās pārdēvēt kategoriju",
"delete_category": "Dzēst kategoriju",
"category_deleted": "Kategorija “{name}” dzēsta",
"category_delete_failed": "Neizdevās dzēst kategoriju",
"undo": "Atsaukt",
"context_menu": {
"open": "Atvērt",
"edit": "Rediģēt",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Vārds",
"sort_last_name": "Uzvārds"
},
"open_categories": "Atvērt kategorijas"
"open_categories": "Atvērt kategorijas",
"row_actions": "Darbības: {name}"
},
"calendar": {
"title": "Kalendārs",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Ангиллын нэр",
"category_renamed": "Ангиллын нэрийг өөрчилсөн",
"category_rename_failed": "Ангиллын нэрийг өөрчилж чадсангүй",
"delete_category": "Ангиллыг устгах",
"category_deleted": "\"{name}\" ангиллыг устгалаа",
"category_delete_failed": "Ангиллыг устгаж чадсангүй",
"undo": "Буцаах",
"category_added": "Харилцагчийг {name}",
"category_added_plural": "{count} харилцагчдыг {name}",
"empty_state": "Харилцагч байхгүй",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "Харилцагч руу буцах",
"back_to_email": "Имэйл рүү буцах",
"open_categories": "Нээлттэй ангилалууд",
"row_actions": "{name}-н үйлдлүүд",
"tabs": {
"all": "Бүгд",
"groups": "Бүлэг"
@@ -2960,7 +2965,10 @@
"delete": "Хаягийн дэвтэр устгах",
"confirm_delete": "\"{name}\"-г устгах уу? Энэ хаягийн дэвтэр дэх бүх харилцагчийг устгах болно.",
"deleted": "Хаягийн дэвтэр устсан",
"delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй"
"delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй",
"move_to": "Зөөх…",
"move_title": "Хаягийн дэвтэр рүү зөөх",
"move_here": "Энд зөөх"
},
"detail": {
"emails": "Имэйл хаягууд",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Kategorinavn",
"category_renamed": "Kategorien har fått nytt navn",
"category_rename_failed": "Kunne ikke gi kategorien nytt navn",
"delete_category": "Slett kategori",
"category_deleted": "Kategorien «{name}» er slettet",
"category_delete_failed": "Kunne ikke slette kategorien",
"undo": "Angre",
"category_added": "Kontakt lagt til i {name}",
"category_added_plural": "{count} kontakter lagt til i {name}",
"empty_state": "Ingen kontakter ennå",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "Tilbake til kontaktene",
"back_to_email": "Tilbake til e-post",
"open_categories": "Åpne kategorier",
"row_actions": "Handlinger for {name}",
"tabs": {
"all": "Alle",
"groups": "Grupper"
@@ -2960,7 +2965,10 @@
"delete": "Slett adressebok",
"confirm_delete": "Vil du slette «{name}»? Alle kontaktene i denne adresseboken fjernes.",
"deleted": "Adresseboken er slettet",
"delete_failed": "Kunne ikke slette adresseboken"
"delete_failed": "Kunne ikke slette adresseboken",
"move_to": "Flytt til…",
"move_title": "Flytt til adressebok",
"move_here": "Flytt hit"
},
"detail": {
"emails": "E-postadresser",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Categorienaam",
"category_renamed": "Categorie hernoemd",
"category_rename_failed": "Categorie hernoemen mislukt",
"delete_category": "Categorie verwijderen",
"category_deleted": "Categorie '{name}' verwijderd",
"category_delete_failed": "Kan de categorie niet verwijderen",
"undo": "Ongedaan maken",
"category_added": "Contact toegevoegd aan {name}",
"category_added_plural": "{count} contacten toegevoegd aan {name}",
"empty_state": "Geen contacten",
@@ -2959,7 +2963,10 @@
"delete": "Adresboek verwijderen",
"confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.",
"deleted": "Adresboek verwijderd",
"delete_failed": "Adresboek kon niet worden verwijderd"
"delete_failed": "Adresboek kon niet worden verwijderd",
"move_to": "Verplaatsen naar…",
"move_title": "Naar adresboek verplaatsen",
"move_here": "Hierheen verplaatsen"
},
"detail": {
"emails": "E-mailadressen",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Voornaam",
"sort_last_name": "Achternaam"
},
"open_categories": "Categorieën openen"
"open_categories": "Categorieën openen",
"row_actions": "Acties voor {name}"
},
"calendar": {
"title": "Agenda",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Nazwa kategorii",
"category_renamed": "Zmieniono nazwę kategorii",
"category_rename_failed": "Nie udało się zmienić nazwy kategorii",
"delete_category": "Usuń kategorię",
"category_deleted": "Usunięto kategorię „{name}”",
"category_delete_failed": "Nie udało się usunąć kategorii",
"undo": "Cofnij",
"category_added": "Kontakt dodany do {name}",
"category_added_plural": "{count} kontaktów dodano do {name}",
"empty_state": "Brak kontaktów",
@@ -2959,7 +2963,10 @@
"delete": "Usuń książkę adresową",
"confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.",
"deleted": "Książka adresowa usunięta",
"delete_failed": "Nie udało się usunąć książki adresowej"
"delete_failed": "Nie udało się usunąć książki adresowej",
"move_to": "Przenieś do…",
"move_title": "Przenieś do książki adresowej",
"move_here": "Przenieś tutaj"
},
"detail": {
"emails": "Adresy e-mail",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Imię",
"sort_last_name": "Nazwisko"
},
"open_categories": "Otwórz kategorie"
"open_categories": "Otwórz kategorie",
"row_actions": "Działania dla {name}"
},
"calendar": {
"title": "Kalendarz",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Nome da categoria",
"category_renamed": "Categoria renomeada",
"category_rename_failed": "Falha ao renomear a categoria",
"delete_category": "Excluir categoria",
"category_deleted": "Categoria \"{name}\" excluída",
"category_delete_failed": "Não foi possível excluir a categoria",
"undo": "Desfazer",
"category_added": "Contato adicionado a {name}",
"category_added_plural": "{count} contatos adicionados a {name}",
"empty_state": "Nenhum contato",
@@ -2959,7 +2963,10 @@
"delete": "Excluir lista de contatos",
"confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.",
"deleted": "Lista de contatos excluída",
"delete_failed": "Falha ao excluir a lista de contatos"
"delete_failed": "Falha ao excluir a lista de contatos",
"move_to": "Mover para…",
"move_title": "Mover para catálogo de endereços",
"move_here": "Mover para cá"
},
"detail": {
"emails": "Endereços de e-mail",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Nome",
"sort_last_name": "Sobrenome"
},
"open_categories": "Abrir categorias"
"open_categories": "Abrir categorias",
"row_actions": "Ações para {name}"
},
"calendar": {
"title": "Calendário",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Numele categoriei",
"category_renamed": "Categorie redenumită",
"category_rename_failed": "Nu s-a putut redenumi categoria",
"delete_category": "Ștergeți categoria",
"category_deleted": "Categoria „{name}” a fost ștearsă",
"category_delete_failed": "Nu s-a putut șterge categoria",
"undo": "Anulați",
"category_added": "Contact adăugat la {name}",
"category_added_plural": "{count} contacte adăugate la {name}",
"empty_state": "Nu există încă contacte",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "Înapoi la contacte",
"back_to_email": "Înapoi la e-mail",
"open_categories": "Deschideți categoriile",
"row_actions": "Acțiuni pentru {name}",
"tabs": {
"all": "Toate",
"groups": "Grupuri"
@@ -2960,7 +2965,10 @@
"delete": "Șterge agenda de adrese",
"confirm_delete": "Doriți să ștergeți „{name}”? Toate contactele din această agendă vor fi eliminate.",
"deleted": "Agenda de adrese a fost ștersă",
"delete_failed": "Nu s-a putut șterge agenda de adrese"
"delete_failed": "Nu s-a putut șterge agenda de adrese",
"move_to": "Mutați în…",
"move_title": "Mutați în agendă",
"move_here": "Mutați aici"
},
"detail": {
"emails": "Adrese de e-mail",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Название категории",
"category_renamed": "Категория переименована",
"category_rename_failed": "Не удалось переименовать категорию",
"delete_category": "Удалить категорию",
"category_deleted": "Категория «{name}» удалена",
"category_delete_failed": "Не удалось удалить категорию",
"undo": "Отменить",
"category_added": "Контакт добавлен в {name}",
"category_added_plural": "{count} контактов добавлено в {name}",
"empty_state": "Контактов пока нет",
@@ -2959,7 +2963,10 @@
"delete": "Удалить адресную книгу",
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
"deleted": "Адресная книга удалена",
"delete_failed": "Не удалось удалить адресную книгу"
"delete_failed": "Не удалось удалить адресную книгу",
"move_to": "Переместить в…",
"move_title": "Переместить в адресную книгу",
"move_here": "Переместить сюда"
},
"detail": {
"emails": "Адреса электронной почты",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Имя",
"sort_last_name": "Фамилия"
},
"open_categories": "Открыть категории"
"open_categories": "Открыть категории",
"row_actions": "Действия для {name}"
},
"calendar": {
"title": "Календарь",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Názov kategórie",
"category_renamed": "Kategória bola premenovaná",
"category_rename_failed": "Premenovanie kategórie zlyhalo",
"delete_category": "Odstrániť kategóriu",
"category_deleted": "Kategória „{name}“ bola odstránená",
"category_delete_failed": "Kategóriu sa nepodarilo odstrániť",
"undo": "Späť",
"category_added": "Kontakt pridaný do {name}",
"category_added_plural": "{count} kontaktov bolo pridaných do {name}",
"empty_state": "Žiadne kontakty",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "Späť na kontakty",
"back_to_email": "Späť na e-mail",
"open_categories": "Otvoriť kategórie",
"row_actions": "Akcie pre {name}",
"tabs": {
"all": "Všetky",
"groups": "Skupiny"
@@ -2960,7 +2965,10 @@
"delete": "Zmazať adresár",
"confirm_delete": "Zmazať \"{name}\"? Všetky kontakty v tomto adresári budú odstránené.",
"deleted": "Adresár zmazaný",
"delete_failed": "Adresár sa nepodarilo zmazať"
"delete_failed": "Adresár sa nepodarilo zmazať",
"move_to": "Presunúť do…",
"move_title": "Presunúť do adresára",
"move_here": "Presunúť sem"
},
"detail": {
"emails": "E-mailové adresy",
+9 -1
View File
@@ -2246,6 +2246,10 @@
"category_name_label": "ชื่อหมวดหมู่",
"category_renamed": "เปลี่ยนชื่อหมวดหมู่แล้ว",
"category_rename_failed": "เปลี่ยนชื่อหมวดหมู่ไม่สำเร็จ",
"delete_category": "ลบหมวดหมู่",
"category_deleted": "ลบหมวดหมู่ \"{name}\" แล้ว",
"category_delete_failed": "ลบหมวดหมู่ไม่ได้",
"undo": "เลิกทำ",
"category_added": "เพิ่มผู้ติดต่อใน {name} แล้ว",
"category_added_plural": "เพิ่มผู้ติดต่อ {count} รายการใน {name} แล้ว",
"empty_state": "ยังไม่มีผู้ติดต่อ",
@@ -2263,6 +2267,7 @@
"back_to_contacts": "กลับไปยังผู้ติดต่อ",
"back_to_email": "กลับไปยังอีเมล",
"open_categories": "เปิดหมวดหมู่",
"row_actions": "การทำงานสำหรับ {name}",
"tabs": {
"all": "ทั้งหมด",
"groups": "กลุ่มผู้ติดต่อ"
@@ -2295,7 +2300,10 @@
"delete": "ลบสมุดที่อยู่",
"confirm_delete": "ลบ “{name}” หรือไม่? ผู้ติดต่อทั้งหมดในสมุดที่อยู่นี้จะถูกลบ",
"deleted": "ลบสมุดที่อยู่แล้ว",
"delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ"
"delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ",
"move_to": "ย้ายไปที่…",
"move_title": "ย้ายไปยังสมุดที่อยู่",
"move_here": "ย้ายมาที่นี่"
},
"detail": {
"emails": "ที่อยู่อีเมล",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Kategori adı",
"category_renamed": "Kategori yeniden adlandırıldı",
"category_rename_failed": "Kategori yeniden adlandırılamadı",
"delete_category": "Kategoriyi sil",
"category_deleted": "\"{name}\" kategorisi silindi",
"category_delete_failed": "Kategori silinemedi",
"undo": "Geri al",
"category_added": "{name} kategorisine kişi eklendi",
"category_added_plural": "{name} kategorisine {count} kişi eklendi",
"empty_state": "Henüz kişi yok",
@@ -2959,7 +2963,10 @@
"delete": "Adres defterini sil",
"confirm_delete": "\"{name}\" silinsin mi? Bu adres defterindeki tüm kişiler kaldırılacak.",
"deleted": "Adres defteri silindi",
"delete_failed": "Adres defteri silinemedi"
"delete_failed": "Adres defteri silinemedi",
"move_to": "Taşı…",
"move_title": "Adres defterine taşı",
"move_here": "Buraya taşı"
},
"detail": {
"emails": "E-posta Adresleri",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Ad",
"sort_last_name": "Soyad"
},
"open_categories": "Kategorileri aç"
"open_categories": "Kategorileri aç",
"row_actions": "{name} için işlemler"
},
"calendar": {
"title": "Takvim",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "Назва категорії",
"category_renamed": "Категорію перейменовано",
"category_rename_failed": "Не вдалося перейменувати категорію",
"delete_category": "Видалити категорію",
"category_deleted": "Категорію «{name}» видалено",
"category_delete_failed": "Не вдалося видалити категорію",
"undo": "Скасувати",
"category_added": "Контакт додано до {name}",
"category_added_plural": "{count} контактів додано до {name}",
"empty_state": "Контактів ще немає",
@@ -2959,7 +2963,10 @@
"delete": "Видалити адресну книгу",
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
"deleted": "Адресну книгу видалено",
"delete_failed": "Не вдалося видалити адресну книгу"
"delete_failed": "Не вдалося видалити адресну книгу",
"move_to": "Перемістити до…",
"move_title": "Перемістити до адресної книги",
"move_here": "Перемістити сюди"
},
"detail": {
"emails": "Адреси електронної пошти",
@@ -3248,7 +3255,8 @@
"sort_first_name": "Ім'я",
"sort_last_name": "Прізвище"
},
"open_categories": "Відкрити категорії"
"open_categories": "Відкрити категорії",
"row_actions": "Дії для {name}"
},
"calendar": {
"title": "Календар",
+9 -1
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "類別名稱",
"category_renamed": "類別已重新命名",
"category_rename_failed": "重新命名類別失敗",
"delete_category": "刪除類別",
"category_deleted": "已刪除類別「{name}」",
"category_delete_failed": "無法刪除類別",
"undo": "復原",
"category_added": "聯絡人已新增至 {name}",
"category_added_plural": "{count} 位聯絡人已新增至 {name}",
"empty_state": "尚無聯絡人",
@@ -2928,6 +2932,7 @@
"back_to_contacts": "回到聯絡人",
"back_to_email": "回到郵件",
"open_categories": "開啟類別",
"row_actions": "{name} 的動作",
"tabs": {
"all": "全部",
"groups": "群組"
@@ -2960,7 +2965,10 @@
"delete": "刪除通訊錄",
"confirm_delete": "要刪除「{name}」嗎?此通訊錄中的所有聯絡人都會移除。",
"deleted": "通訊錄已刪除",
"delete_failed": "刪除通訊錄失敗"
"delete_failed": "刪除通訊錄失敗",
"move_to": "移至…",
"move_title": "移至通訊錄",
"move_here": "移到這裡"
},
"detail": {
"emails": "電子郵件地址",
+10 -2
View File
@@ -2911,6 +2911,10 @@
"category_name_label": "类别名称",
"category_renamed": "类别已重命名",
"category_rename_failed": "重命名类别失败",
"delete_category": "删除类别",
"category_deleted": "已删除类别“{name}”",
"category_delete_failed": "无法删除类别",
"undo": "撤销",
"category_added": "联系人已添加至 {name}",
"category_added_plural": "{count} 联系人已添加到 {name}",
"empty_state": "还没有联系人",
@@ -2959,7 +2963,10 @@
"delete": "删除通讯录",
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
"deleted": "通讯录已删除",
"delete_failed": "删除通讯录失败"
"delete_failed": "删除通讯录失败",
"move_to": "移动到…",
"move_title": "移动到通讯录",
"move_here": "移到此处"
},
"detail": {
"emails": "邮箱地址",
@@ -3248,7 +3255,8 @@
"sort_first_name": "名",
"sort_last_name": "姓"
},
"open_categories": "打开分类"
"open_categories": "打开分类",
"row_actions": "{name} 的操作"
},
"calendar": {
"title": "日历",
+46
View File
@@ -463,6 +463,10 @@ interface ContactStore {
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
/** Take a category off every contact; returns the ids of the contacts that had it, for undo. */
removeKeyword: (client: IJMAPClient | null, keyword: string) => Promise<string[]>;
/** Put a category on contacts, e.g. to undo removing it. */
addKeyword: (client: IJMAPClient | null, keyword: string, contactIds: string[]) => Promise<void>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
@@ -1405,6 +1409,48 @@ export const useContactStore = create<ContactStore>()(
}
},
removeKeyword: async (client, keyword) => {
set({ error: null });
const kw = keyword.trim();
const { contacts, supportsSync } = get();
const affected = contacts.filter(c => c.keywords?.[kw]);
const done: string[] = [];
for (const contact of affected) {
const { [kw]: _gone, ...rest } = contact.keywords || {};
void _gone;
if (supportsSync && client) {
const originalId = contact.originalId || contact.id;
const accountId = contact.isShared ? contact.accountId : undefined;
// JMAP patch: only this keyword goes; the card's others stay.
await client.updateContact(originalId, { keywords: rest }, accountId);
}
set((state) => ({
contacts: state.contacts.map(c => (c.id === contact.id ? { ...c, keywords: rest } : c)),
}));
done.push(contact.id);
}
return done;
},
addKeyword: async (client, keyword, contactIds) => {
set({ error: null });
const kw = keyword.trim();
const { contacts, supportsSync } = get();
for (const id of contactIds) {
const contact = contacts.find(c => c.id === id);
if (!contact || contact.keywords?.[kw]) continue;
const keywords = { ...(contact.keywords || {}), [kw]: true };
if (supportsSync && client) {
const originalId = contact.originalId || contact.id;
const accountId = contact.isShared ? contact.accountId : undefined;
await client.updateContact(originalId, { keywords }, accountId);
}
set((state) => ({
contacts: state.contacts.map(c => (c.id === id ? { ...c, keywords } : c)),
}));
}
},
loadRecentRecipients: async (client, sentMailboxId) => {
if (sentMailboxId) set({ sentMailboxId });
if (get().recentRecipientsLoaded || !sentMailboxId) return;